Terminate WebSocket Stream when Session is Deleted (#3515)

This commit is contained in:
Jonathan Dobson
2025-09-24 13:11:44 -04:00
committed by GitHub
parent e25d553d2a
commit 27ecd9f3df
2 changed files with 79 additions and 20 deletions

View File

@@ -1,7 +1,9 @@
import RFB from "@novnc/novnc/lib/rfb.js"; import RFB from "@novnc/novnc/lib/rfb.js";
import { ExitIcon, HandIcon } from "@radix-ui/react-icons"; import { ExitIcon, HandIcon } from "@radix-ui/react-icons";
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { Status } from "@/api/types"; import { Status } from "@/api/types";
import type { import type {
TaskApiResponse, TaskApiResponse,
@@ -24,6 +26,11 @@ import { cn } from "@/util/utils";
import { RotateThrough } from "./RotateThrough"; import { RotateThrough } from "./RotateThrough";
import "./browser-stream.css"; import "./browser-stream.css";
interface BrowserSession {
browser_session_id: string;
completed_at?: string;
}
interface CommandTakeControl { interface CommandTakeControl {
kind: "take-control"; kind: "take-control";
} }
@@ -73,11 +80,41 @@ function BrowserStream({
entity = "task"; entity = "task";
} else if (workflow) { } else if (workflow) {
runId = workflow.run.workflow_run_id; runId = workflow.run.workflow_run_id;
browserSessionId = workflow.run.browser_session_id ?? undefined;
showStream = statusIsNotFinalized(workflow.run); showStream = statusIsNotFinalized(workflow.run);
entity = "workflow"; entity = "workflow";
} else { } else {
throw new Error("No browser session, task or workflow provided"); throw new Error("No browser session id, task or workflow provided");
} }
useQuery({
queryKey: ["browserSession", browserSessionId],
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
try {
const response = await client.get<BrowserSession | null>(
`/browser_sessions/${browserSessionId}`,
);
const browserSession = response.data;
if (!browserSession || browserSession.completed_at) {
setHasBrowserSession(false);
return false;
}
setHasBrowserSession(true);
return true;
} catch (error) {
setHasBrowserSession(false);
return false;
}
},
enabled: !!browserSessionId,
refetchInterval: 5000,
});
const [hasBrowserSession, setHasBrowserSession] = useState(true); // be optimistic
const [userIsControlling, setUserIsControlling] = useState(interactive); const [userIsControlling, setUserIsControlling] = useState(interactive);
const [commandSocket, setCommandSocket] = useState<WebSocket | null>(null); const [commandSocket, setCommandSocket] = useState<WebSocket | null>(null);
const [vncDisconnectedTrigger, setVncDisconnectedTrigger] = useState(0); const [vncDisconnectedTrigger, setVncDisconnectedTrigger] = useState(0);
@@ -87,8 +124,6 @@ function BrowserStream({
useState(0); useState(0);
const prevCommandConnectedRef = useRef<boolean>(false); const prevCommandConnectedRef = useRef<boolean>(false);
const [isCommandConnected, setIsCommandConnected] = useState<boolean>(false); const [isCommandConnected, setIsCommandConnected] = useState<boolean>(false);
// goes up a level
// const queryClient = useQueryClient();
const [canvasContainer, setCanvasContainer] = useState<HTMLDivElement | null>( const [canvasContainer, setCanvasContainer] = useState<HTMLDivElement | null>(
null, null,
); );
@@ -172,6 +207,11 @@ function BrowserStream({
rfbRef.current.disconnect(); rfbRef.current.disconnect();
} }
if (!hasBrowserSession) {
setIsVncConnected(false);
return;
}
const canvas = canvasContainer; const canvas = canvasContainer;
if (!canvas) { if (!canvas) {
@@ -191,6 +231,8 @@ function BrowserStream({
rfb.addEventListener("disconnect", async (/* e: RfbEvent */) => { rfb.addEventListener("disconnect", async (/* e: RfbEvent */) => {
setIsVncConnected(false); setIsVncConnected(false);
}); });
// setIsVncConnected(true); // be optimistic
} }
setupVnc(); setupVnc();
@@ -206,11 +248,13 @@ function BrowserStream({
// cannot include isVncConnected in deps as it will cause infinite loop // cannot include isVncConnected in deps as it will cause infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[ [
browserSessionId,
entity,
canvasContainer, canvasContainer,
hasBrowserSession,
runId,
showStream, showStream,
vncDisconnectedTrigger, // will re-run on disconnects vncDisconnectedTrigger, // will re-run on disconnects
runId,
entity,
], ],
); );
@@ -238,6 +282,11 @@ function BrowserStream({
throw new Error("No command url"); throw new Error("No command url");
} }
if (!hasBrowserSession) {
setIsCommandConnected(false);
return;
}
ws = new WebSocket(commandUrl); ws = new WebSocket(commandUrl);
ws.onopen = () => { ws.onopen = () => {
@@ -261,10 +310,12 @@ function BrowserStream({
} }
}; };
}, [ }, [
browserSessionId,
canvasContainer, canvasContainer,
commandDisconnectedTrigger, commandDisconnectedTrigger,
entity, entity,
getWebSocketParams, getWebSocketParams,
hasBrowserSession,
runId, runId,
showStream, showStream,
]); ]);
@@ -351,7 +402,7 @@ function BrowserStream({
)} )}
ref={setCanvasContainerRef} ref={setCanvasContainerRef}
> >
{isVncConnected && ( {isVncConnected && hasBrowserSession && (
<div className="overlay z-10 flex items-center justify-center overflow-hidden"> <div className="overlay z-10 flex items-center justify-center overflow-hidden">
{showControlButtons && ( {showControlButtons && (
<div className="control-buttons pointer-events-none relative flex h-full w-full items-center justify-center"> <div className="control-buttons pointer-events-none relative flex h-full w-full items-center justify-center">
@@ -387,18 +438,24 @@ function BrowserStream({
</div> </div>
)} )}
{!isVncConnected && ( {!isVncConnected && (
<div className="absolute left-0 top-1/2 flex aspect-video w-full -translate-y-1/2 flex-col items-center justify-center gap-2 rounded-md border border-slate-800 text-sm text-slate-400"> <div className="absolute left-0 top-1/2 flex aspect-video max-h-full w-full -translate-y-1/2 flex-col items-center justify-center gap-2 rounded-md border border-slate-800 text-sm text-slate-400">
<RotateThrough interval={7 * 1000}> {browserSessionId && !hasBrowserSession ? (
<span>Hm, working on the connection...</span> <div>This live browser session is no longer streaming.</div>
<span>Hang tight, we're almost there...</span> ) : (
<span>Just a moment...</span> <>
<span>Backpropagating...</span> <RotateThrough interval={7 * 1000}>
<span>Attention is all I need...</span> <span>Hm, working on the connection...</span>
<span>Consulting the manual...</span> <span>Hang tight, we're almost there...</span>
<span>Looking for the bat phone...</span> <span>Just a moment...</span>
<span>Where's Shu?...</span> <span>Backpropagating...</span>
</RotateThrough> <span>Attention is all I need...</span>
<AnimatedWave text=".‧₊˚ ⋅ ? ✨ ?★ ‧₊˚ ⋅" /> <span>Consulting the manual...</span>
<span>Looking for the bat phone...</span>
<span>Where's Shu?...</span>
</RotateThrough>
<AnimatedWave text=".‧₊˚ ⋅ ? ✨ ?★ ‧₊˚ ⋅" />
</>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -8,12 +8,12 @@ import {
} from "react"; } from "react";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { import {
CheckIcon,
ChevronRightIcon, ChevronRightIcon,
ChevronLeftIcon, ChevronLeftIcon,
CopyIcon,
GlobeIcon, GlobeIcon,
ReloadIcon, ReloadIcon,
CheckIcon,
CopyIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useParams, useSearchParams } from "react-router-dom"; import { useParams, useSearchParams } from "react-router-dom";
import { useEdgesState, useNodesState, Edge } from "@xyflow/react"; import { useEdgesState, useNodesState, Edge } from "@xyflow/react";
@@ -124,6 +124,8 @@ function CopyText({ text }: { text: string }) {
); );
} }
export { CopyText };
function Workspace({ function Workspace({
initialNodes, initialNodes,
initialEdges, initialEdges,