Skip to content

Commit 3c5cc82

Browse files
authored
Stop the live screen only for the socket that is showing it (#191)
* Stop the live screen only for the socket that is showing it A second /stream for a Bot replaces the first: open stops whatever was casting and puts the new socket in the session. It does not close the socket it replaced, so that one closes whenever its client gets round to it, which on an ordinary reconnect is after the replacement is already running. close then stopped the session's viewer without asking whether the closing socket was the one casting, so it stopped the new one. Both halves of the failure are silent. The screen stops updating, and the person's input is dropped without a word, because the input path checks for a viewer before it checks anything it can report. The decision goes in its own file for the reason bot-id.ts and authorisation.ts are separate: index.ts imports Playwright at module scope, so anything left in it needs Chrome merely to be imported by a test. * Say in the changelog that a live-screen reconnect stops killing itself
1 parent 5947759 commit 3c5cc82

4 files changed

Lines changed: 93 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,19 @@ A thread the platform does not have now reads as having no messages. A 404 and o
107107
a 500, because an outage answered with an empty history would tell the browser the conversation is gone
108108
and invite somebody to start it over.
109109

110+
### Reconnecting a live screen no longer stops the screen you just reconnected
111+
112+
A Bot's screen allows one viewer, and opening a second `/stream` replaces the first. The replaced
113+
socket is left open, because it belongs to a client that may still be using it, so on an ordinary
114+
reconnect, where the browser opens the new connection before dropping the old one, the old socket
115+
closed after the new one was already casting. Closing it stopped the session's viewer without asking
116+
whether the closing socket was the one casting, so it stopped the replacement.
117+
118+
Both halves were silent. The screen stopped updating, and anything typed afterwards was dropped
119+
without a word, because the input path looks for a viewer before it looks for anything it can report.
120+
121+
A close now stops casting only when the socket closing is the one that was casting.
122+
110123
## 0.0.4
111124

112125
### A click citing a ref this deployment cannot resolve is refused

agent-computer/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
startScreencast,
2020
} from "./screencast";
2121
import { createShell } from "./shell";
22+
import { isCurrentViewer } from "./viewer";
2223
import {
2324
createWorkspace,
2425
WorkspaceFileError,
@@ -441,7 +442,11 @@ serve<StreamData>({
441442
},
442443

443444
async close(ws) {
444-
await stopViewer(sessionFor(ws.data.botId));
445+
const session = sessionFor(ws.data.botId);
446+
// Only the socket that is casting. A superseded one closing after its replacement has started
447+
// would otherwise stop the new viewer; see viewer.ts.
448+
if (!isCurrentViewer(session.viewer, ws)) return;
449+
await stopViewer(session);
445450
},
446451
},
447452
async fetch(request, server) {

agent-computer/src/viewer.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Which socket is allowed to stop the live screen.
3+
*
4+
* A Bot's screen has one viewer, and a second `/stream` replaces the first rather than being
5+
* refused: `open` stops whatever was casting and puts the new socket in the session. What it does
6+
* not do is close the socket it replaced, because that socket belongs to a client that may still be
7+
* using it. So the superseded socket closes on its own schedule, and on an ordinary reconnect, where
8+
* a client opens the new connection before dropping the old one, that is after the replacement is
9+
* already casting.
10+
*
11+
* A `close` handler that stops the session's viewer without asking whether the closing socket is the
12+
* one casting therefore stops the wrong viewer. The screen the person just reconnected to goes quiet,
13+
* and their input is dropped without a word, because the input path checks for a viewer before it
14+
* checks anything it could report. Both failures are silent; the browser is fine, the Bot is fine,
15+
* and the person is looking at a still image.
16+
*
17+
* It lives in its own file for the reason `bot-id.ts` and `authorisation.ts` do: `index.ts` imports
18+
* Playwright at module scope, so anything left in it needs Chrome merely to be imported by a test.
19+
* The decision is here and the stopping stays there, the same split `browser-eviction.ts` makes.
20+
*/
21+
22+
/**
23+
* Is this socket the one currently casting?
24+
*
25+
* By identity, never by value. Two sockets are distinct objects however alike they look, and an
26+
* equality that compared their contents would put the bug back for any pair that happened to match.
27+
*/
28+
export function isCurrentViewer(
29+
current: { socket: unknown } | undefined,
30+
socket: unknown,
31+
): boolean {
32+
return current?.socket === socket;
33+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { isCurrentViewer } from "../src/viewer";
3+
4+
/**
5+
* Which socket is allowed to stop the live screen.
6+
*
7+
* One viewer per Bot, and a second `/stream` replaces the first: `open` stops whatever was casting
8+
* and puts the new socket in the session. The old socket is not closed by that, so its `close`
9+
* arrives whenever the client gets round to it, which on an ordinary make-before-break reconnect is
10+
* after the replacement is already running. A close that stops the current viewer without asking
11+
* whether it owns it stops the wrong one, and the person who just reconnected gets a screen that
12+
* never updates and input that goes nowhere.
13+
*
14+
* The decision rather than the stopping. Stopping a cast is Playwright's job and is not where the
15+
* wrong answer was; `browser-eviction.ts` splits the same way and for the same reason.
16+
*/
17+
describe("deciding whether a closing socket stops the live screen", () => {
18+
const socket = { id: "a" };
19+
const other = { id: "b" };
20+
21+
test("the socket that is casting stops it", () => {
22+
expect(isCurrentViewer({ socket }, socket)).toBe(true);
23+
});
24+
25+
test("a socket that was replaced stops nothing", () => {
26+
// The bug this exists for. The old socket closes after the new one has taken over, and without
27+
// this the new viewer is the one that gets stopped.
28+
expect(isCurrentViewer({ socket: other }, socket)).toBe(false);
29+
});
30+
31+
test("a close with no viewer at all stops nothing", () => {
32+
// Both sockets gone, or the cast never started. There is nothing to stop and nothing to get wrong.
33+
expect(isCurrentViewer(undefined, socket)).toBe(false);
34+
});
35+
36+
test("identity, not shape", () => {
37+
// Two sockets are never equal by value, and comparing them that way would put the bug back for
38+
// any pair that happened to look alike.
39+
expect(isCurrentViewer({ socket: { id: "a" } }, { id: "a" })).toBe(false);
40+
});
41+
});

0 commit comments

Comments
 (0)