From 4e384209445d740454f1508939599c14dc53cada Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 23:49:45 +0000 Subject: [PATCH 1/3] fix(plugin-browser): finish browser replay follow-ups Forget a session after delete or move settles, and stop the tab-list wait when that desktop closes. Attach still has no stable desktop id. --- packages/plugin-browser/src/connection.ts | 22 +++++++++---- .../plugin-browser/test/connection.test.ts | 33 +++++++++++++++++-- packages/plugin-browser/test/rpc.test.ts | 17 +++++++++- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/plugin-browser/src/connection.ts b/packages/plugin-browser/src/connection.ts index ab22444a73ed..d09f17d5ca53 100644 --- a/packages/plugin-browser/src/connection.ts +++ b/packages/plugin-browser/src/connection.ts @@ -139,8 +139,8 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( } // `replaced` already installed the next attachment. `closed` is an ordinary drop: // give the desktop one reconnect attempt before reporting that the action did not run. - // Attach carries a fresh connection id, not a stable desktop id, so this replay can - // land on a different desktop that attaches to the same session. + // Attach carries sessionID, connectionID, and version. connectionID is new on every + // registration, and the desktop does not send a stable id, so replay follows the session. const adopt = (sessionID: Session.ID, previous: Attachment, reason: "closed" | "replaced") => Effect.gen(function* () { if (retired.has(sessionID)) return undefined @@ -282,6 +282,12 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( Effect.andThen(close(event.data.sessionID)), // close wakes an in-flight command, which may park a waiter before this line. Effect.andThen(forget(event.data.sessionID)), + // Both forgets have run, and adopt already observed the flag, so the id can leave the set. + Effect.andThen( + Effect.sync(() => { + retired.delete(event.data.sessionID) + }), + ), ), ), Effect.forkScoped({ startImmediately: true }), @@ -303,6 +309,7 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( return { status, waiting: (sessionID: Session.ID) => subscribers.get(sessionID)?.length ?? 0, + retired: (sessionID: Session.ID) => retired.has(sessionID), // Fails before a browser tool reads files or dispatches. Status stays available either way. require: Effect.fn("BrowserConnection.require")(function* (sessionID: Session.ID) { if (browsers.has(sessionID)) return @@ -362,10 +369,7 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( const request = Effect.fn("BrowserConnection.request")(function* ( rpc: RpcRegistration, - adopt: ( - previous: Attachment, - reason: "closed" | "replaced", - ) => Effect.Effect, + adopt: (previous: Attachment, reason: "closed" | "replaced") => Effect.Effect, browser: Attachment, action: Browser.Action, tab: Browser.Tab | undefined, @@ -449,7 +453,11 @@ const tabAfterInventory = Effect.fn("BrowserConnection.tabAfterInventory")(funct action: Browser.Action, ) { if (!("tabID" in action)) return undefined - yield* Deferred.await(next.inventory).pipe( + // A desktop that closes while we wait must not hold the command for the whole grace. + yield* Effect.raceFirst( + Deferred.await(next.inventory), + Deferred.await(next.closed).pipe(Effect.andThen(Effect.fail(new Tool.Error({ message: closedBefore })))), + ).pipe( Effect.timeoutOrElse({ duration: inventoryGrace, orElse: () => Effect.fail(new Tool.Error({ message: closedBefore })), diff --git a/packages/plugin-browser/test/connection.test.ts b/packages/plugin-browser/test/connection.test.ts index 26d9d06507f1..a5fc46bb8838 100644 --- a/packages/plugin-browser/test/connection.test.ts +++ b/packages/plugin-browser/test/connection.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { Cause, Effect, Exit, Fiber, Queue, Stream } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Option, Queue, Stream } from "effect" import { TestClock } from "effect/testing" import type { Context } from "@opencode/plugin/effect/plugin" import { AbsolutePath } from "@opencode/schema/schema" @@ -370,7 +370,34 @@ test("a replacement replays a tab command after the new desktop publishes its ta ) }) -test("an ordinary reconnect replays a command the desktop never took", async () => { +test("closing the new desktop ends the tab-list wait", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const app = yield* open + yield* app.connect("desktop-a") + yield* app.methods.state({ sessionID, connectionID: "desktop-a", state }, call) + const pending = yield* app + .invoke("browser.snapshot", { tabID }) + .pipe(Effect.forkScoped({ startImmediately: true })) + const settled = yield* Deferred.make>() + yield* Effect.exit(Fiber.join(pending)).pipe( + Effect.flatMap((exit) => Deferred.succeed(settled, exit)), + Effect.forkScoped({ startImmediately: true }), + ) + yield* app.take("command", "desktop-a") + const next = yield* app.connect("desktop-b") + yield* Fiber.interrupt(next) + yield* Effect.yieldNow + const ready = yield* Deferred.poll(settled) + if (Option.isNone(ready)) return yield* Effect.die(new Error("tab-list wait ignored the desktop closing")) + expect(message(yield* ready.value)).toContain("did not run") + }), + ).pipe(Effect.provide(TestClock.layer())), + ) +}) + +test("an ordinary reconnect replays onto the new connection id", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { @@ -382,6 +409,7 @@ test("an ordinary reconnect replays a command the desktop never took", async () yield* app.connect("desktop-b") const replayed = yield* app.take("command", "desktop-b") if (replayed.type !== "command") return yield* Effect.die(new Error("expected the reconnected command")) + expect(replayed.connectionID).toBe("desktop-b") yield* app.methods.command({ sessionID, connectionID: "desktop-b", requestID: replayed.requestID }, call) yield* app.methods.result( { @@ -453,6 +481,7 @@ for (const type of ["session.deleted", "session.moved"] as const) { yield* app.end(type) yield* Effect.yieldNow expect(app.connection.waiting(sessionID)).toBe(0) + expect(app.connection.retired(sessionID)).toBe(false) const failure = message(yield* Effect.exit(Fiber.join(pending))) expect(failure).toContain("did not run") }), diff --git a/packages/plugin-browser/test/rpc.test.ts b/packages/plugin-browser/test/rpc.test.ts index af1d5b53f05b..d0e0c017217f 100644 --- a/packages/plugin-browser/test/rpc.test.ts +++ b/packages/plugin-browser/test/rpc.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test" -import { Browser } from "../src/rpc.js" import { Schema } from "effect" +import { Session } from "@opencode/schema/session" +import { Browser } from "../src/rpc.js" const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`) @@ -59,6 +60,20 @@ test("network lifecycle and RPC version are explicit", () => { expect(Schema.decodeUnknownSync(Browser.Definition.methods.attach.output)("replaced")).toBe("replaced") }) +test("attach keeps session, connection, and version, and drops a desktop id", () => { + const decoded = Schema.decodeUnknownSync(Browser.Definition.methods.attach.input)({ + sessionID: Session.ID.make("ses_browser"), + connectionID: "connection-1", + version: 4, + desktopID: "desk-1", + }) + expect(decoded.sessionID).toBe(Session.ID.make("ses_browser")) + expect(decoded.connectionID).toBe("connection-1") + expect(decoded.version).toBe(4) + expect("desktopID" in decoded).toBe(false) + expect(Object.keys(decoded).sort()).toEqual(["connectionID", "sessionID", "version"]) +}) + // Tool search matches query words as substrings of the description, folding a trailing "s"/"es". // Each phrase an agent is likely to search for when it wants the user to see a file must hit. test.each([ From 844f089a9bea8d9cf1d3461a5cb54e38a2215e70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 23:56:11 +0000 Subject: [PATCH 2/3] fix(plugin-browser): keep a session retired until its grace ends close only schedules the command it wakes, so deleting the retired id before adopt runs lets that command park or replay. The wake carries a retired reason, and the id stays banned for one reconnect grace. Overlapping move and delete extend that deadline. --- packages/plugin-browser/src/connection.ts | 74 +++++++++++++------ .../plugin-browser/test/connection.test.ts | 62 +++++++++++++++- 2 files changed, 111 insertions(+), 25 deletions(-) diff --git a/packages/plugin-browser/src/connection.ts b/packages/plugin-browser/src/connection.ts index d09f17d5ca53..02d45a79a3bf 100644 --- a/packages/plugin-browser/src/connection.ts +++ b/packages/plugin-browser/src/connection.ts @@ -4,7 +4,7 @@ import type { Context } from "@opencode/plugin/effect/plugin" import type { RpcRegistration } from "@opencode/plugin/effect/rpc" import type { Session } from "@opencode/schema/session" import { Tool } from "@opencode/schema/tool" -import { Deferred, Effect, Option, Schema, Stream } from "effect" +import { Clock, Deferred, Duration, Effect, Option, Schema, Stream } from "effect" import { Browser } from "./rpc.js" import { BrowserTunnel } from "./tunnel.js" @@ -30,6 +30,8 @@ export const attached = export const replayGrace = "2 seconds" export const inventoryGrace = "2 seconds" +type CloseReason = "closed" | "replaced" | "retired" + type Pending = { command: Browser.Command result: Deferred.Deferred @@ -40,7 +42,7 @@ type Pending = { type Attachment = { connectionID: string state: Browser.State - closed: Deferred.Deferred<"closed" | "replaced"> + closed: Deferred.Deferred /** Completed by the first `state` publication, which is the first real tab list. */ inventory: Deferred.Deferred inventoryReady: boolean @@ -65,8 +67,9 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( const browsers = new Map() // In-flight commands waiting for an ordinary reconnect, not a replacement that is already installed. const subscribers = new Map>>() - // Set while a delete or move is settling, so that close cannot park a new waiter. - const retired = new Set() + // Session ids that must not replay, and the time that ban lifts. A move or delete + // extends the deadline instead of clearing another event's entry. + const retiredUntil = new Map() // Appended after config, so every agent hides the browser until an attachment allows it. yield* ctx.agent.transform((editor) => editor.list().forEach((agent) => agent.permissions.push({ action: "browser", resource: "*", effect: "deny" })), @@ -123,7 +126,7 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( { discard: true }, ) } - const close = (sessionID: Session.ID, reason: "closed" | "replaced" = "closed") => + const close = (sessionID: Session.ID, reason: CloseReason = "closed") => Effect.gen(function* () { // Drop anyone still parked from an earlier grace window before this close wakes them. yield* forget(sessionID) @@ -141,16 +144,42 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( // give the desktop one reconnect attempt before reporting that the action did not run. // Attach carries sessionID, connectionID, and version. connectionID is new on every // registration, and the desktop does not send a stable id, so replay follows the session. - const adopt = (sessionID: Session.ID, previous: Attachment, reason: "closed" | "replaced") => + const isRetired = (sessionID: Session.ID) => + Effect.gen(function* () { + const deadline = retiredUntil.get(sessionID) + if (deadline === undefined) return false + if ((yield* Clock.currentTimeMillis) < deadline) return true + retiredUntil.delete(sessionID) + return false + }) + // Holds the ban for one reconnect grace. A later move or delete only lengthens it. + const markRetired = (sessionID: Session.ID) => Effect.gen(function* () { - if (retired.has(sessionID)) return undefined + const until = (yield* Clock.currentTimeMillis) + Duration.toMillis(replayGrace) + const current = retiredUntil.get(sessionID) + if (current === undefined || until > current) retiredUntil.set(sessionID, until) + yield* Effect.sleep(replayGrace).pipe( + Effect.andThen( + Effect.gen(function* () { + const deadline = retiredUntil.get(sessionID) + if (deadline !== undefined && (yield* Clock.currentTimeMillis) >= deadline) retiredUntil.delete(sessionID) + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }) + const adopt = (sessionID: Session.ID, previous: Attachment, reason: CloseReason) => + Effect.gen(function* () { + // The reason travels with the wake. close only schedules this command, so a + // flag cleared by the handler would already be gone by the time we run. + if (reason === "retired" || (yield* isRetired(sessionID))) return undefined const installed = browsers.get(sessionID) if (installed && installed !== previous) return installed if (reason === "replaced") return undefined const waiter = yield* Deferred.make() subscribers.set(sessionID, [...(subscribers.get(sessionID) ?? []), waiter]) const raced = browsers.get(sessionID) - if (retired.has(sessionID)) { + if (yield* isRetired(sessionID)) { drop(sessionID, waiter) return undefined } @@ -196,14 +225,14 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( const browser: Attachment = { connectionID: input.connectionID, state: { tabs: [], focusedTabID: null }, - closed: yield* Deferred.make<"closed" | "replaced">(), + closed: yield* Deferred.make(), inventory: yield* Deferred.make(), inventoryReady: false, pending: new Map(), tunnels: BrowserTunnel.make(), } browsers.set(input.sessionID, browser) - retired.delete(input.sessionID) + // A new attachment does not lift a delete or move ban early. The deadline does. // Wake reconnect waiters before the previous command observes `replaced` or `closed`. yield* announce(input.sessionID, browser) if (previous) yield* Deferred.succeed(previous.closed, "replaced") @@ -215,7 +244,9 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( yield* rpc.events .emit("control", { type: "attached", connectionID: input.connectionID, version: 4 }) .pipe(Effect.orDie) - return yield* Deferred.await(browser.closed) + // In-flight commands see "retired". The desktop protocol only has closed and replaced. + const outcome = yield* Deferred.await(browser.closed) + return outcome === "retired" ? "closed" : outcome }).pipe(Effect.scoped), state: (input, call) => Effect.gen(function* () { @@ -276,18 +307,13 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( yield* ctx.event.subscribe().pipe( Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"), Stream.runForEach((event) => - Effect.sync(() => { - retired.add(event.data.sessionID) - }).pipe( - Effect.andThen(close(event.data.sessionID)), - // close wakes an in-flight command, which may park a waiter before this line. + markRetired(event.data.sessionID).pipe( + Effect.andThen(close(event.data.sessionID, "retired")), + // close only schedules the command it wakes. That command reads the retired + // reason, so it does not park a reconnect after this handler returns. Effect.andThen(forget(event.data.sessionID)), - // Both forgets have run, and adopt already observed the flag, so the id can leave the set. - Effect.andThen( - Effect.sync(() => { - retired.delete(event.data.sessionID) - }), - ), + // The id stays retired for replayGrace. Another move or delete extends that + // deadline; clearing it here would let a late adopt replay onto a session that came back. ), ), Effect.forkScoped({ startImmediately: true }), @@ -309,7 +335,7 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( return { status, waiting: (sessionID: Session.ID) => subscribers.get(sessionID)?.length ?? 0, - retired: (sessionID: Session.ID) => retired.has(sessionID), + retired: (sessionID: Session.ID) => retiredUntil.has(sessionID), // Fails before a browser tool reads files or dispatches. Status stays available either way. require: Effect.fn("BrowserConnection.require")(function* (sessionID: Session.ID) { if (browsers.has(sessionID)) return @@ -369,7 +395,7 @@ export const make = Effect.fn("BrowserConnection.make")(function* ( const request = Effect.fn("BrowserConnection.request")(function* ( rpc: RpcRegistration, - adopt: (previous: Attachment, reason: "closed" | "replaced") => Effect.Effect, + adopt: (previous: Attachment, reason: CloseReason) => Effect.Effect, browser: Attachment, action: Browser.Action, tab: Browser.Tab | undefined, diff --git a/packages/plugin-browser/test/connection.test.ts b/packages/plugin-browser/test/connection.test.ts index a5fc46bb8838..e865c00ec96c 100644 --- a/packages/plugin-browser/test/connection.test.ts +++ b/packages/plugin-browser/test/connection.test.ts @@ -481,7 +481,7 @@ for (const type of ["session.deleted", "session.moved"] as const) { yield* app.end(type) yield* Effect.yieldNow expect(app.connection.waiting(sessionID)).toBe(0) - expect(app.connection.retired(sessionID)).toBe(false) + expect(app.connection.retired(sessionID)).toBe(true) const failure = message(yield* Effect.exit(Fiber.join(pending))) expect(failure).toContain("did not run") }), @@ -490,6 +490,66 @@ for (const type of ["session.deleted", "session.moved"] as const) { }) } +for (const type of ["session.deleted", "session.moved"] as const) { + test(`${type} fails a taken command while the desktop is still attached`, async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const app = yield* open + yield* app.connect("desktop-a") + const pending = yield* app.invoke("browser.tabs.list", {}).pipe(Effect.forkScoped({ startImmediately: true })) + const settled = yield* Deferred.make>() + yield* Effect.exit(Fiber.join(pending)).pipe( + Effect.flatMap((exit) => Deferred.succeed(settled, exit)), + Effect.forkScoped({ startImmediately: true }), + ) + const event = yield* app.take("command", "desktop-a") + if (event.type !== "command") return yield* Effect.die(new Error("expected a browser command")) + yield* app.methods.command({ sessionID, connectionID: "desktop-a", requestID: event.requestID }, call) + yield* app.end(type) + yield* Effect.yieldNow + const ready = yield* Deferred.poll(settled) + if (Option.isNone(ready)) return yield* Effect.die(new Error("command waited after the session ended")) + expect(message(yield* ready.value)).toContain("may already have run") + expect(app.connection.waiting(sessionID)).toBe(0) + }), + ).pipe(Effect.provide(TestClock.layer())), + ) + }) +} + +test("overlapping move and delete fail a taken command and then release the session", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const app = yield* open + yield* app.connect("desktop-a") + const pending = yield* app.invoke("browser.tabs.list", {}).pipe(Effect.forkScoped({ startImmediately: true })) + const settled = yield* Deferred.make>() + yield* Effect.exit(Fiber.join(pending)).pipe( + Effect.flatMap((exit) => Deferred.succeed(settled, exit)), + Effect.forkScoped({ startImmediately: true }), + ) + const event = yield* app.take("command", "desktop-a") + if (event.type !== "command") return yield* Effect.die(new Error("expected a browser command")) + yield* app.methods.command({ sessionID, connectionID: "desktop-a", requestID: event.requestID }, call) + yield* app.end("session.moved") + yield* app.end("session.deleted") + yield* Effect.yieldNow + yield* Effect.yieldNow + const ready = yield* Deferred.poll(settled) + if (Option.isNone(ready)) return yield* Effect.die(new Error("command waited after overlapping session events")) + expect(message(yield* ready.value)).toContain("may already have run") + expect(app.connection.waiting(sessionID)).toBe(0) + expect(app.connection.retired(sessionID)).toBe(true) + yield* TestClock.adjust(BrowserConnection.replayGrace) + yield* Effect.yieldNow + expect(app.connection.retired(sessionID)).toBe(false) + }), + ).pipe(Effect.provide(TestClock.layer())), + ) +}) + test("a close that already has the result keeps that result", async () => { await Effect.runPromise( Effect.scoped( From 26b3019176216b5b2d6aef1051001a792ed636ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 00:04:19 +0000 Subject: [PATCH 3/3] test(plugin-browser): exercise the retired-session ban Leave the command untaken so delete and move reach adopt, and sequence a move and a later delete across the reconnect grace. --- .../plugin-browser/test/connection.test.ts | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/packages/plugin-browser/test/connection.test.ts b/packages/plugin-browser/test/connection.test.ts index e865c00ec96c..2918697b8e20 100644 --- a/packages/plugin-browser/test/connection.test.ts +++ b/packages/plugin-browser/test/connection.test.ts @@ -491,7 +491,7 @@ for (const type of ["session.deleted", "session.moved"] as const) { } for (const type of ["session.deleted", "session.moved"] as const) { - test(`${type} fails a taken command while the desktop is still attached`, async () => { + test(`${type} fails an untaken command while the desktop is still attached`, async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { @@ -503,14 +503,39 @@ for (const type of ["session.deleted", "session.moved"] as const) { Effect.flatMap((exit) => Deferred.succeed(settled, exit)), Effect.forkScoped({ startImmediately: true }), ) - const event = yield* app.take("command", "desktop-a") - if (event.type !== "command") return yield* Effect.die(new Error("expected a browser command")) - yield* app.methods.command({ sessionID, connectionID: "desktop-a", requestID: event.requestID }, call) + // Seen by the desktop, but not retrieved, so the close reaches adopt. + yield* app.take("command", "desktop-a") yield* app.end(type) yield* Effect.yieldNow const ready = yield* Deferred.poll(settled) if (Option.isNone(ready)) return yield* Effect.die(new Error("command waited after the session ended")) - expect(message(yield* ready.value)).toContain("may already have run") + const failure = message(yield* ready.value) + expect(failure).toContain("did not run") + expect(failure).not.toContain("may already have run") + expect(app.connection.waiting(sessionID)).toBe(0) + // The ban outlives the handler, so this command cannot be replayed onto a later attach. + expect(app.connection.retired(sessionID)).toBe(true) + const again = yield* app.connect("desktop-b") + yield* Effect.yieldNow + const replayed: string[] = [] + let queued = yield* Queue.poll(app.events) + while (Option.isSome(queued)) { + if (queued.value.type === "command") replayed.push(queued.value.connectionID) + queued = yield* Queue.poll(app.events) + } + expect(replayed).toEqual([]) + const next = yield* app.invoke("browser.tabs.list", {}).pipe(Effect.forkScoped({ startImmediately: true })) + const nextSettled = yield* Deferred.make>() + yield* Effect.exit(Fiber.join(next)).pipe( + Effect.flatMap((exit) => Deferred.succeed(nextSettled, exit)), + Effect.forkScoped({ startImmediately: true }), + ) + yield* app.take("command", "desktop-b") + yield* Fiber.interrupt(again) + yield* Effect.yieldNow + const dropped = yield* Deferred.poll(nextSettled) + if (Option.isNone(dropped)) return yield* Effect.die(new Error("re-attach drop waited out the ban")) + expect(message(yield* dropped.value)).toContain("did not run") expect(app.connection.waiting(sessionID)).toBe(0) }), ).pipe(Effect.provide(TestClock.layer())), @@ -518,31 +543,25 @@ for (const type of ["session.deleted", "session.moved"] as const) { }) } -test("overlapping move and delete fail a taken command and then release the session", async () => { +test("a later delete extends the ban started by a move", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const app = yield* open yield* app.connect("desktop-a") const pending = yield* app.invoke("browser.tabs.list", {}).pipe(Effect.forkScoped({ startImmediately: true })) - const settled = yield* Deferred.make>() - yield* Effect.exit(Fiber.join(pending)).pipe( - Effect.flatMap((exit) => Deferred.succeed(settled, exit)), - Effect.forkScoped({ startImmediately: true }), - ) - const event = yield* app.take("command", "desktop-a") - if (event.type !== "command") return yield* Effect.die(new Error("expected a browser command")) - yield* app.methods.command({ sessionID, connectionID: "desktop-a", requestID: event.requestID }, call) + yield* app.take("command", "desktop-a") yield* app.end("session.moved") + yield* Effect.yieldNow + expect(message(yield* Effect.exit(Fiber.join(pending)))).toContain("did not run") + expect(app.connection.waiting(sessionID)).toBe(0) + yield* TestClock.adjust("1 second") yield* app.end("session.deleted") yield* Effect.yieldNow + yield* TestClock.adjust("1 second") yield* Effect.yieldNow - const ready = yield* Deferred.poll(settled) - if (Option.isNone(ready)) return yield* Effect.die(new Error("command waited after overlapping session events")) - expect(message(yield* ready.value)).toContain("may already have run") - expect(app.connection.waiting(sessionID)).toBe(0) expect(app.connection.retired(sessionID)).toBe(true) - yield* TestClock.adjust(BrowserConnection.replayGrace) + yield* TestClock.adjust("1 second") yield* Effect.yieldNow expect(app.connection.retired(sessionID)).toBe(false) }),