Skip to content

Commit 458fcc2

Browse files
authored
New ConnMonitor to Track "Stalled" Connection State for SSH Conns (#2846)
* ConnMonitor to track stalled connections * New Stalled Overlay to show feedback when we think a connection is stalled * New Icon in ConnButton to show stalled connections * Callbacks in domain socket and PTYs to track activity
1 parent 2c8928b commit 458fcc2

File tree

23 files changed

+516
-69
lines changed

23 files changed

+516
-69
lines changed

cmd/server/main-server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,7 @@ func main() {
600600
// use fmt instead of log here to make sure it goes directly to stderr
601601
fmt.Fprintf(os.Stderr, "WAVESRV-ESTART ws:%s web:%s version:%s buildtime:%s\n", wsListener.Addr(), webListener.Addr(), WaveVersion, BuildTime)
602602
}()
603-
go wshutil.RunWshRpcOverListener(unixListener)
603+
go wshutil.RunWshRpcOverListener(unixListener, nil)
604604
web.RunWebServer(webListener) // blocking
605605
runtime.KeepAlive(waveLock)
606606
}

cmd/wsh/cmd/wshcmd-connserver.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func handleNewListenerConn(conn net.Conn, router *wshutil.WshRouter) {
9797
router.UnregisterLink(baseds.LinkId(linkId))
9898
}
9999
}()
100-
wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh)
100+
wshutil.AdaptStreamToMsgCh(conn, proxy.FromRemoteCh, nil)
101101
}()
102102
linkId := router.RegisterUntrustedLink(proxy)
103103
linkIdContainer.Store(int32(linkId))
@@ -265,7 +265,7 @@ func serverRunRouterDomainSocket(jwtToken string) error {
265265
log.Printf("upstream domain socket closed, shutting down")
266266
wshutil.DoShutdown("", 0, true)
267267
}()
268-
wshutil.AdaptStreamToMsgCh(conn, upstreamProxy.FromRemoteCh)
268+
wshutil.AdaptStreamToMsgCh(conn, upstreamProxy.FromRemoteCh, nil)
269269
}()
270270

271271
// register the domain socket connection as upstream

emain/emain.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,16 @@ async function appMain() {
412412
fireAndForget(createNewWaveWindow);
413413
}
414414
});
415+
electron.powerMonitor.on("resume", () => {
416+
console.log("system resumed from sleep, notifying server");
417+
fireAndForget(async () => {
418+
try {
419+
await RpcApi.NotifySystemResumeCommand(ElectronWshClient, { noresponse: true });
420+
} catch (e) {
421+
console.log("error calling NotifySystemResumeCommand", e);
422+
}
423+
});
424+
});
415425
const rawGlobalHotKey = launchSettings?.["app:globalhotkey"];
416426
if (rawGlobalHotKey) {
417427
registerGlobalHotkey(rawGlobalHotKey);

frontend/app/block/block.scss

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,8 @@
284284
.connstatus-overlay {
285285
position: absolute;
286286
top: calc(var(--header-height) + 6px);
287-
left: 6px;
288-
right: 6px;
287+
left: 8px;
288+
right: 8px;
289289
z-index: var(--zindex-block-mask-inner);
290290
display: flex;
291291
align-items: center;
@@ -296,6 +296,7 @@
296296
backdrop-filter: blur(50px);
297297
border-radius: 6px;
298298
box-shadow: 0px 13px 16px 0px rgb(from var(--block-bg-color) r g b / 40%);
299+
opacity: 0.85;
299300

300301
.connstatus-content {
301302
display: flex;

frontend/app/block/connectionbutton.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ export const ConnectionButton = React.memo(
8080
color = "var(--grey-text-color)";
8181
titleText = "Disconnected from " + connection;
8282
showDisconnectedSlash = true;
83+
} else if (connStatus?.connhealthstatus === "degraded" || connStatus?.connhealthstatus === "stalled") {
84+
color = "var(--warning-color)";
85+
iconName = "signal-bars-slash";
86+
if (connStatus.connhealthstatus === "degraded") {
87+
titleText = "Connection degraded: " + connection;
88+
} else {
89+
titleText = "Connection stalled: " + connection;
90+
}
8391
}
8492
if (iconSvg != null) {
8593
connIconElem = iconSvg;

frontend/app/block/connstatusoverlay.tsx

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,100 @@ import * as jotai from "jotai";
1414
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
1515
import * as React from "react";
1616

17+
function formatElapsedTime(elapsedMs: number): string {
18+
if (elapsedMs <= 0) {
19+
return "";
20+
}
21+
22+
const elapsedSeconds = Math.floor(elapsedMs / 1000);
23+
24+
if (elapsedSeconds < 60) {
25+
return `${elapsedSeconds}s`;
26+
}
27+
28+
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
29+
if (elapsedMinutes < 60) {
30+
return `${elapsedMinutes}m`;
31+
}
32+
33+
const elapsedHours = Math.floor(elapsedMinutes / 60);
34+
const remainingMinutes = elapsedMinutes % 60;
35+
36+
if (elapsedHours < 24) {
37+
if (remainingMinutes === 0) {
38+
return `${elapsedHours}h`;
39+
}
40+
return `${elapsedHours}h${remainingMinutes}m`;
41+
}
42+
43+
return "more than a day";
44+
}
45+
46+
const StalledOverlay = React.memo(
47+
({
48+
connName,
49+
connStatus,
50+
overlayRefCallback,
51+
}: {
52+
connName: string;
53+
connStatus: ConnStatus;
54+
overlayRefCallback: (el: HTMLDivElement | null) => void;
55+
}) => {
56+
const [elapsedTime, setElapsedTime] = React.useState<string>("");
57+
58+
const handleDisconnect = React.useCallback(() => {
59+
const prtn = RpcApi.ConnDisconnectCommand(TabRpcClient, connName, { timeout: 5000 });
60+
prtn.catch((e) => console.log("error disconnecting", connName, e));
61+
}, [connName]);
62+
63+
React.useEffect(() => {
64+
if (!connStatus.lastactivitybeforestalledtime) {
65+
return;
66+
}
67+
68+
const updateElapsed = () => {
69+
const now = Date.now();
70+
const lastActivity = connStatus.lastactivitybeforestalledtime!;
71+
const elapsed = now - lastActivity;
72+
setElapsedTime(formatElapsedTime(elapsed));
73+
};
74+
75+
updateElapsed();
76+
const interval = setInterval(updateElapsed, 1000);
77+
78+
return () => clearInterval(interval);
79+
}, [connStatus.lastactivitybeforestalledtime]);
80+
81+
return (
82+
<div
83+
className="@container absolute top-[calc(var(--header-height)+6px)] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden rounded-md bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] shadow-lg opacity-85"
84+
ref={overlayRefCallback}
85+
>
86+
<div className="flex items-center gap-3 w-full pt-2.5 pb-2.5 pr-2 pl-3">
87+
<i
88+
className="fa-solid fa-triangle-exclamation text-warning text-base shrink-0"
89+
title="Connection Stalled"
90+
></i>
91+
<div className="text-[11px] font-semibold leading-4 tracking-[0.11px] text-white min-w-0 flex-1 break-words @max-xxs:hidden">
92+
Connection to "{connName}" is stalled
93+
{elapsedTime && ` (no activity for ${elapsedTime})`}
94+
</div>
95+
<div className="flex-1 hidden @max-xxs:block"></div>
96+
<Button
97+
className="outlined grey text-[11px] py-[3px] px-[7px] @max-w350:text-[12px] @max-w350:py-[5px] @max-w350:px-[6px]"
98+
onClick={handleDisconnect}
99+
title="Disconnect"
100+
>
101+
<span className="@max-w350:hidden!">Disconnect</span>
102+
<i className="fa-solid fa-link-slash hidden! @max-w350:inline!"></i>
103+
</Button>
104+
</div>
105+
</div>
106+
);
107+
}
108+
);
109+
StalledOverlay.displayName = "StalledOverlay";
110+
17111
export const ConnStatusOverlay = React.memo(
18112
({
19113
nodeModel,
@@ -121,10 +215,17 @@ export const ConnStatusOverlay = React.memo(
121215
[showError, showWshError, connStatus.error, connStatus.wsherror]
122216
);
123217

124-
if (!showWshError && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) {
218+
let showStalled = connStatus.status == "connected" && connStatus.connhealthstatus == "stalled";
219+
if (!showWshError && !showStalled && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) {
125220
return null;
126221
}
127222

223+
if (showStalled && !showWshError) {
224+
return (
225+
<StalledOverlay connName={connName} connStatus={connStatus} overlayRefCallback={overlayRefCallback} />
226+
);
227+
}
228+
128229
return (
129230
<div className="connstatus-overlay" ref={overlayRefCallback}>
130231
<div className="connstatus-content">

frontend/app/store/wshclientapi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,11 @@ class RpcApiType {
512512
return client.wshRpcCall("notify", data, opts);
513513
}
514514

515+
// command "notifysystemresume" [call]
516+
NotifySystemResumeCommand(client: WshClient, opts?: RpcOpts): Promise<void> {
517+
return client.wshRpcCall("notifysystemresume", null, opts);
518+
}
519+
515520
// command "path" [call]
516521
PathCommand(client: WshClient, data: PathCommandData, opts?: RpcOpts): Promise<string> {
517522
return client.wshRpcCall("path", data, opts);

frontend/tailwindsetup.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767

6868
--container-w600: 600px;
6969
--container-w450: 450px;
70+
--container-w350: 350px;
7071
--container-xs: 300px;
7172
--container-xxs: 200px;
7273
--container-tiny: 120px;

frontend/types/gotypes.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,7 @@ declare global {
785785
// wshrpc.ConnStatus
786786
type ConnStatus = {
787787
status: string;
788+
connhealthstatus?: string;
788789
wshenabled: boolean;
789790
connection: string;
790791
connected: boolean;
@@ -794,6 +795,8 @@ declare global {
794795
wsherror?: string;
795796
nowshreason?: string;
796797
wshversion?: string;
798+
lastactivitybeforestalledtime?: number;
799+
keepalivesenttime?: number;
797800
};
798801

799802
// wshrpc.CpuDataRequest

pkg/blockcontroller/blockcontroller.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,31 @@ func DestroyBlockController(blockId string) {
290290
deleteController(blockId)
291291
}
292292

293+
func sendConnMonitorInputNotification(controller Controller) {
294+
connName := controller.GetConnName()
295+
if connName == "" || conncontroller.IsLocalConnName(connName) || conncontroller.IsWslConnName(connName) {
296+
return
297+
}
298+
299+
connOpts, parseErr := remote.ParseOpts(connName)
300+
if parseErr != nil {
301+
return
302+
}
303+
sshConn := conncontroller.MaybeGetConn(connOpts)
304+
if sshConn != nil {
305+
monitor := sshConn.GetMonitor()
306+
if monitor != nil {
307+
monitor.NotifyInput()
308+
}
309+
}
310+
}
311+
293312
func SendInput(blockId string, inputUnion *BlockInputUnion) error {
294313
controller := getController(blockId)
295314
if controller == nil {
296315
return fmt.Errorf("no controller found for block %s", blockId)
297316
}
317+
sendConnMonitorInputNotification(controller)
298318
return controller.SendInput(inputUnion)
299319
}
300320

@@ -413,7 +433,10 @@ func CheckConnStatus(blockId string) error {
413433
if err != nil {
414434
return fmt.Errorf("error parsing connection name: %w", err)
415435
}
416-
conn := conncontroller.GetConn(opts)
436+
conn := conncontroller.MaybeGetConn(opts)
437+
if conn == nil {
438+
return fmt.Errorf("no connection found")
439+
}
417440
connStatus := conn.DeriveConnStatus()
418441
if connStatus.Status != conncontroller.Status_Connected {
419442
return fmt.Errorf("not connected: %s", connStatus.Status)

0 commit comments

Comments
 (0)