Skip to content

Commit 4856a7d

Browse files
add inactivity timeout on client disconnect (#3)
* wip * add changes to go & py clients * address comments
1 parent 5f07506 commit 4856a7d

9 files changed

Lines changed: 197 additions & 10 deletions

File tree

go/modcdp/client/ModCDPClient.go

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const DefaultServiceWorkerReadyTimeoutMS = 60_000
5454
const DefaultServiceWorkerPollIntervalMS = 100
5555
const DefaultTargetSessionPollIntervalMS = 20
5656
const DefaultWSConnectErrorSettleTimeoutMS = 250
57+
const DefaultClientHeartbeatIntervalMS = 250
5758

5859
func boolPointer(value bool) *bool {
5960
return &value
@@ -180,14 +181,16 @@ func freePort() (int, error) {
180181
// --- public types --------------------------------------------------------
181182

182183
type ServerConfig struct {
183-
ServerLoopbackCDPURL string `json:"server_loopback_cdp_url,omitempty"`
184-
ServerRoutes map[string]string `json:"server_routes,omitempty"`
185-
ServerBrowserToken string `json:"server_browser_token,omitempty"`
186-
ServerCDPSendTimeoutMS int `json:"server_cdp_send_timeout_ms,omitempty"`
187-
ServerLoopbackExecutionContextTimeoutMS int `json:"server_loopback_execution_context_timeout_ms,omitempty"`
188-
ServerWSConnectErrorSettleTimeoutMS int `json:"server_ws_connect_error_settle_timeout_ms,omitempty"`
189-
Options map[string]any `json:"-"`
190-
disabled bool
184+
ServerLoopbackCDPURL string `json:"server_loopback_cdp_url,omitempty"`
185+
ServerRoutes map[string]string `json:"server_routes,omitempty"`
186+
ServerBrowserToken string `json:"server_browser_token,omitempty"`
187+
ServerCDPSendTimeoutMS int `json:"server_cdp_send_timeout_ms,omitempty"`
188+
ServerLoopbackExecutionContextTimeoutMS int `json:"server_loopback_execution_context_timeout_ms,omitempty"`
189+
ServerWSConnectErrorSettleTimeoutMS int `json:"server_ws_connect_error_settle_timeout_ms,omitempty"`
190+
ServerDownstreamClientTimeoutMS int `json:"server_downstream_client_timeout_ms,omitempty"`
191+
ServerCloseBrowserOnDownstreamDisconnect *bool `json:"server_close_browser_on_downstream_disconnect,omitempty"`
192+
Options map[string]any `json:"-"`
193+
disabled bool
191194
}
192195

193196
var ServerNone = &ServerConfig{disabled: true}
@@ -254,6 +257,7 @@ type ClientConfig struct {
254257
ClientMirrorUpstreamEvents *bool `json:"client_mirror_upstream_events,omitempty"`
255258
ClientCDPSendTimeoutMS int `json:"client_cdp_send_timeout_ms,omitempty"`
256259
ClientEventWaitTimeoutMS int `json:"client_event_wait_timeout_ms,omitempty"`
260+
ClientHeartbeatIntervalMS int `json:"client_heartbeat_interval_ms,omitempty"`
257261
}
258262

259263
type Options struct {
@@ -406,6 +410,7 @@ type ModCDPClient struct {
406410
launchedBrowser *LaunchedBrowser
407411
extensionInjectors []extensionInjector
408412
configuredPeerGeneration int64
413+
heartbeatStop chan struct{}
409414
}
410415

411416
type extensionInjector interface {
@@ -501,6 +506,9 @@ func New(opts Options) *ModCDPClient {
501506
if opts.Client.ClientEventWaitTimeoutMS == 0 {
502507
opts.Client.ClientEventWaitTimeoutMS = DefaultEventWaitTimeoutMS
503508
}
509+
if opts.Client.ClientHeartbeatIntervalMS == 0 {
510+
opts.Client.ClientHeartbeatIntervalMS = DefaultClientHeartbeatIntervalMS
511+
}
504512
if opts.Injector.InjectorExecutionContextTimeoutMS == 0 {
505513
opts.Injector.InjectorExecutionContextTimeoutMS = DefaultExecutionContextTimeoutMS
506514
}
@@ -575,7 +583,10 @@ func (c *ModCDPClient) Connect() error {
575583
return fmt.Errorf("upstream transport did not connect")
576584
}
577585
c.transport.OnRecv(func(message map[string]any) { c.handleMessage(message) })
578-
c.transport.OnClose(func(err error) { c.rejectAll(err) })
586+
c.transport.OnClose(func(err error) {
587+
c.stopHeartbeat()
588+
c.rejectAll(err)
589+
})
579590
if transportpkg.EndpointKindForUpstream(c.Upstream.UpstreamMode) == UpstreamEndpointKindModCDPServer {
580591
if err := c.transport.WaitForPeer(); err != nil {
581592
c.Close()
@@ -588,6 +599,7 @@ func (c *ModCDPClient) Connect() error {
588599
}
589600
c.configuredPeerGeneration = c.transport.PeerGeneration()
590601
}
602+
c.startHeartbeat()
591603
c.startPingLatencyMeasurement()
592604
connectedAt := time.Now().UnixMilli()
593605
c.ConnectTiming = map[string]any{
@@ -683,6 +695,7 @@ func (c *ModCDPClient) Connect() error {
683695
return fmt.Errorf("Mod.configure: %w", err)
684696
}
685697
}
698+
c.startHeartbeat()
686699
c.startPingLatencyMeasurement()
687700
connectedAt := time.Now().UnixMilli()
688701
c.ConnectTiming = map[string]any{
@@ -899,6 +912,7 @@ func (c *ModCDPClient) serverConfigureParams(customCommands []map[string]any, cu
899912
"server_cdp_send_timeout_ms": c.Client.ClientCDPSendTimeoutMS,
900913
"server_loopback_execution_context_timeout_ms": c.Injector.InjectorExecutionContextTimeoutMS,
901914
"server_ws_connect_error_settle_timeout_ms": c.Upstream.UpstreamWSConnectErrorSettleTimeoutMS,
915+
"server_downstream_client_timeout_ms": maxInt(c.Client.ClientHeartbeatIntervalMS*4, 1_000),
902916
}
903917
if c.Server != nil {
904918
server["server_loopback_cdp_url"] = c.Server.ServerLoopbackCDPURL
@@ -915,6 +929,12 @@ func (c *ModCDPClient) serverConfigureParams(customCommands []map[string]any, cu
915929
if c.Server.ServerWSConnectErrorSettleTimeoutMS != 0 {
916930
server["server_ws_connect_error_settle_timeout_ms"] = c.Server.ServerWSConnectErrorSettleTimeoutMS
917931
}
932+
if c.Server.ServerDownstreamClientTimeoutMS != 0 {
933+
server["server_downstream_client_timeout_ms"] = c.Server.ServerDownstreamClientTimeoutMS
934+
}
935+
if c.Server.ServerCloseBrowserOnDownstreamDisconnect != nil {
936+
server["server_close_browser_on_downstream_disconnect"] = *c.Server.ServerCloseBrowserOnDownstreamDisconnect
937+
}
918938
for key, value := range c.Server.Options {
919939
server[key] = value
920940
}
@@ -1410,6 +1430,7 @@ func handlerPointer(handler Handler) uintptr {
14101430
}
14111431

14121432
func (c *ModCDPClient) Close() {
1433+
c.stopHeartbeat()
14131434
if c.launchedBrowser != nil {
14141435
c.launchedBrowser.Close()
14151436
c.launchedBrowser = nil
@@ -1664,6 +1685,48 @@ func (c *ModCDPClient) startPingLatencyMeasurement() {
16641685
}()
16651686
}
16661687

1688+
func (c *ModCDPClient) startHeartbeat() {
1689+
c.stopHeartbeat()
1690+
if c.Server == nil || c.Server.ServerCloseBrowserOnDownstreamDisconnect == nil || !*c.Server.ServerCloseBrowserOnDownstreamDisconnect {
1691+
return
1692+
}
1693+
interval := c.Client.ClientHeartbeatIntervalMS
1694+
if interval <= 0 {
1695+
return
1696+
}
1697+
stop := make(chan struct{})
1698+
c.heartbeatStop = stop
1699+
go func() {
1700+
ticker := time.NewTicker(time.Duration(interval) * time.Millisecond)
1701+
defer ticker.Stop()
1702+
for {
1703+
select {
1704+
case <-ticker.C:
1705+
if _, err := c.Send("Mod.ping", map[string]any{"sent_at": time.Now().UnixMilli()}); err != nil {
1706+
return
1707+
}
1708+
case <-stop:
1709+
return
1710+
}
1711+
}
1712+
}()
1713+
}
1714+
1715+
func (c *ModCDPClient) stopHeartbeat() {
1716+
if c.heartbeatStop == nil {
1717+
return
1718+
}
1719+
close(c.heartbeatStop)
1720+
c.heartbeatStop = nil
1721+
}
1722+
1723+
func maxInt(left int, right int) int {
1724+
if left > right {
1725+
return left
1726+
}
1727+
return right
1728+
}
1729+
16671730
func numberAsInt64(value any) (int64, bool) {
16681731
switch v := value.(type) {
16691732
case int64:

go/modcdp/injector/extension.zip

1.06 KB
Binary file not shown.

js/src/client/ModCDPClient.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const DEFAULT_SERVICE_WORKER_READY_TIMEOUT_MS = 60_000;
6969
export const DEFAULT_SERVICE_WORKER_POLL_INTERVAL_MS = 100;
7070
export const DEFAULT_TARGET_SESSION_POLL_INTERVAL_MS = 20;
7171
export const DEFAULT_WS_CONNECT_ERROR_SETTLE_TIMEOUT_MS = 250;
72+
export const DEFAULT_CLIENT_HEARTBEAT_INTERVAL_MS = 250;
7273
export const DEFAULT_MODCDP_SERVICE_WORKER_URL_SUFFIXES = ["/modcdp/service_worker.js"];
7374
export const DEFAULT_UPSTREAM_REVERSEWS_BIND = "127.0.0.1:29292";
7475
export const DEFAULT_UPSTREAM_REVERSEWS_WAIT_TIMEOUT_MS = 10_000;
@@ -126,6 +127,7 @@ export type ClientConfigOptions = {
126127
client_mirror_upstream_events?: boolean;
127128
client_cdp_send_timeout_ms?: number;
128129
client_event_wait_timeout_ms?: number;
130+
client_heartbeat_interval_ms?: number;
129131
};
130132
export type ClientOptions = {
131133
launcher?: LauncherOptions;
@@ -143,6 +145,7 @@ type ClientConfig = {
143145
client_mirror_upstream_events: boolean;
144146
client_cdp_send_timeout_ms: number;
145147
client_event_wait_timeout_ms: number;
148+
client_heartbeat_interval_ms: number;
146149
};
147150
type NormalizedClientOptions = {
148151
launcher: Required<LauncherOptions>;
@@ -293,6 +296,7 @@ function normalizeClientOptions({
293296
client_mirror_upstream_events: client.client_mirror_upstream_events ?? true,
294297
client_cdp_send_timeout_ms: client.client_cdp_send_timeout_ms ?? DEFAULT_CDP_SEND_TIMEOUT_MS,
295298
client_event_wait_timeout_ms: client.client_event_wait_timeout_ms ?? DEFAULT_EVENT_WAIT_TIMEOUT_MS,
299+
client_heartbeat_interval_ms: client.client_heartbeat_interval_ms ?? DEFAULT_CLIENT_HEARTBEAT_INTERVAL_MS,
296300
},
297301
server:
298302
server === null
@@ -399,6 +403,7 @@ export class ModCDPClient extends ModCDPEventEmitter {
399403
cdp_aliases_hydrated: boolean;
400404
event_wait_cleanups: Set<() => void>;
401405
auto_sessions: AutoSessionRouter;
406+
heartbeat_timer: ReturnType<typeof setInterval> | null;
402407
_injectors: ExtensionInjector[];
403408
_cdp: {
404409
send: (method: string, params?: ProtocolParams, sessionId?: string | null) => Promise<ProtocolResult>;
@@ -453,6 +458,7 @@ export class ModCDPClient extends ModCDPEventEmitter {
453458
this.command_result_unwrap_keys = new Map();
454459
this.cdp_aliases_hydrated = false;
455460
this.event_wait_cleanups = new Set();
461+
this.heartbeat_timer = null;
456462
this.auto_sessions = new AutoSessionRouter(
457463
(method, params = {}, session_id = null) =>
458464
this._sendMessage(method, params, session_id) as Promise<ProtocolResult>,
@@ -483,6 +489,7 @@ export class ModCDPClient extends ModCDPEventEmitter {
483489
const transport_connected_at = Date.now();
484490
this.transport?.onRecv((message) => this._onRecv(message));
485491
this.transport?.onClose((error) => {
492+
this._stopHeartbeat();
486493
if (this.pending.size > 0) this._rejectAll(error);
487494
});
488495

@@ -492,6 +499,7 @@ export class ModCDPClient extends ModCDPEventEmitter {
492499
if (this.server !== null) {
493500
await this._sendMessage("Mod.configure", this._serverConfigureParams(), null);
494501
}
502+
this._startHeartbeat();
495503
void this._measurePingLatency().catch(() => {});
496504
const connected_at = Date.now();
497505
this.connect_timing = {
@@ -542,6 +550,7 @@ export class ModCDPClient extends ModCDPEventEmitter {
542550
);
543551
}
544552

553+
this._startHeartbeat();
545554
void this._measurePingLatency().catch(() => {});
546555
const connected_at = Date.now();
547556
this.connect_timing = {
@@ -772,6 +781,7 @@ export class ModCDPClient extends ModCDPEventEmitter {
772781
server_cdp_send_timeout_ms: this.client.client_cdp_send_timeout_ms,
773782
server_loopback_execution_context_timeout_ms: this.injector.injector_execution_context_timeout_ms,
774783
server_ws_connect_error_settle_timeout_ms: this.upstream.upstream_ws_connect_error_settle_timeout_ms,
784+
server_downstream_client_timeout_ms: Math.max(this.client.client_heartbeat_interval_ms * 4, 1_000),
775785
};
776786
}
777787

@@ -1016,6 +1026,7 @@ export class ModCDPClient extends ModCDPEventEmitter {
10161026
}
10171027

10181028
async close() {
1029+
this._stopHeartbeat();
10191030
for (const cleanup of this.event_wait_cleanups) cleanup();
10201031
this.event_wait_cleanups.clear();
10211032
if (this._launched) await this._launched.close();
@@ -1026,6 +1037,21 @@ export class ModCDPClient extends ModCDPEventEmitter {
10261037
this._injectors = [];
10271038
}
10281039

1040+
_startHeartbeat() {
1041+
this._stopHeartbeat();
1042+
if (this.server?.server_close_browser_on_downstream_disconnect !== true) return;
1043+
const interval_ms = this.client.client_heartbeat_interval_ms;
1044+
this.heartbeat_timer = setInterval(() => {
1045+
void this.send("Mod.ping", { sent_at: Date.now() }).catch(() => {});
1046+
}, interval_ms);
1047+
}
1048+
1049+
_stopHeartbeat() {
1050+
if (this.heartbeat_timer == null) return;
1051+
clearInterval(this.heartbeat_timer);
1052+
this.heartbeat_timer = null;
1053+
}
1054+
10291055
on<TEvent extends z.ZodType & ModCDPNamedValue>(
10301056
event_name: TEvent,
10311057
listener: (event: ModCDPEventPayload<TEvent>) => void,

js/src/server/ModCDPServer.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const DEFAULT_NATIVE_BRIDGE_HOST_NAME = "com.modcdp.bridge";
3232
export const DEFAULT_NATIVE_BRIDGE_RECONNECT_INTERVAL_MS = 2_000;
3333
export const DEFAULT_NATS_BRIDGE_RECONNECT_INTERVAL_MS = 2_000;
3434
export const DEFAULT_NATS_BRIDGE_SUBJECT_PREFIX = "modcdp.default";
35+
export const DEFAULT_DOWNSTREAM_CLIENT_TIMEOUT_MS = 1_000;
3536

3637
type MiddlewarePhase = "request" | "response" | "event";
3738
type ProtocolCommandSchema = {
@@ -89,6 +90,43 @@ export function installModCDPServer(globalScope: ModCDPGlobalScope = globalThis
8990
};
9091
const attachedDebuggees = new Set<string>();
9192
let runtime_types_promise: Promise<unknown> | null = null;
93+
let downstream_client_registered = false;
94+
let downstream_client_lease: {
95+
cdpSessionId: string | null;
96+
last_seen_at: number;
97+
timer: ReturnType<typeof setTimeout>;
98+
} | null = null;
99+
100+
function registerDownstreamClient() {
101+
downstream_client_registered = true;
102+
}
103+
104+
function clearDownstreamClientLease() {
105+
const lease = downstream_client_lease;
106+
if (!lease) return null;
107+
clearTimeout(lease.timer);
108+
downstream_client_lease = null;
109+
return lease;
110+
}
111+
112+
function touchDownstreamClientLease(cdpSessionId: string | null) {
113+
const timeout_ms = ModCDPServer.downstream_client_timeout_ms;
114+
if (!(timeout_ms > 0)) return;
115+
if (!downstream_client_registered) return;
116+
const last_seen_at = Date.now();
117+
clearDownstreamClientLease();
118+
const timer = setTimeout(() => {
119+
const expired = clearDownstreamClientLease();
120+
if (!expired) return;
121+
if (ModCDPServer.close_browser_on_downstream_disconnect !== true) return;
122+
void ModCDPServer.sendLoopback("Browser.close", {}, null).catch(() => {});
123+
}, timeout_ms);
124+
downstream_client_lease = {
125+
cdpSessionId,
126+
last_seen_at,
127+
timer,
128+
};
129+
}
92130

93131
function nativeCommandSchema(method: string) {
94132
return (nativeCommandSchemas as Record<string, ProtocolCommandSchema>)[method];
@@ -1002,6 +1040,8 @@ export function installModCDPServer(globalScope: ModCDPGlobalScope = globalThis
10021040
cdp_send_timeout_ms: DEFAULT_CDP_SEND_TIMEOUT_MS,
10031041
loopback_execution_context_timeout_ms: DEFAULT_LOOPBACK_EXECUTION_CONTEXT_TIMEOUT_MS,
10041042
ws_connect_error_settle_timeout_ms: DEFAULT_WS_CONNECT_ERROR_SETTLE_TIMEOUT_MS,
1043+
downstream_client_timeout_ms: DEFAULT_DOWNSTREAM_CLIENT_TIMEOUT_MS,
1044+
close_browser_on_downstream_disconnect: false,
10051045
types: null as (typeof import("../types/generated/zod.js"))["types"] | null,
10061046
commands: null as (typeof import("../types/generated/zod.js"))["commands"] | null,
10071047
events: null as (typeof import("../types/generated/zod.js"))["events"] | null,
@@ -1088,13 +1128,17 @@ export function installModCDPServer(globalScope: ModCDPGlobalScope = globalThis
10881128
server_cdp_send_timeout_ms = this.cdp_send_timeout_ms,
10891129
server_loopback_execution_context_timeout_ms = this.loopback_execution_context_timeout_ms,
10901130
server_ws_connect_error_settle_timeout_ms = this.ws_connect_error_settle_timeout_ms,
1131+
server_downstream_client_timeout_ms = this.downstream_client_timeout_ms,
1132+
server_close_browser_on_downstream_disconnect = this.close_browser_on_downstream_disconnect,
10911133
} = server;
10921134
const { custom_commands = [], custom_events = [], custom_middlewares = [] } = params;
10931135
this.loopback_cdp_url = await resolveCDPEndpoint(server_loopback_cdp_url);
10941136
this.browser_token = server_browser_token;
10951137
this.cdp_send_timeout_ms = server_cdp_send_timeout_ms;
10961138
this.loopback_execution_context_timeout_ms = server_loopback_execution_context_timeout_ms;
10971139
this.ws_connect_error_settle_timeout_ms = server_ws_connect_error_settle_timeout_ms;
1140+
this.downstream_client_timeout_ms = server_downstream_client_timeout_ms;
1141+
this.close_browser_on_downstream_disconnect = server_close_browser_on_downstream_disconnect;
10981142
if (upstream.upstream_mode === "nats" && upstream.upstream_nats_url) {
10991143
this.startNatsBridge(upstream.upstream_nats_url, {
11001144
upstream_nats_subject_prefix: upstream.upstream_nats_subject_prefix ?? DEFAULT_NATS_BRIDGE_SUBJECT_PREFIX,
@@ -1217,6 +1261,8 @@ export function installModCDPServer(globalScope: ModCDPGlobalScope = globalThis
12171261
},
12181262

12191263
async handleCommand(method: string, params: ProtocolParams = {}, cdpSessionId: string | null = null) {
1264+
if (method === "Mod.configure") registerDownstreamClient();
1265+
touchDownstreamClientLease(cdpSessionId);
12201266
const request = { method, params, cdpSessionId };
12211267
const middlewareParams = await this.runMiddleware("request", method, params, { cdpSessionId, request });
12221268
if (middlewareParams == null) throw new Error(`Request middleware for ${method} returned no params.`);

js/src/types/modcdp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export const ModCDPClientOptionsSchema = z
169169
client_mirror_upstream_events: z.boolean().optional(),
170170
client_cdp_send_timeout_ms: z.number().positive().optional(),
171171
client_event_wait_timeout_ms: z.number().positive().optional(),
172+
client_heartbeat_interval_ms: z.number().positive().optional(),
172173
})
173174
.passthrough();
174175
export type ModCDPClientOptions = z.infer<typeof ModCDPClientOptionsSchema>;
@@ -181,6 +182,8 @@ export const ModCDPServerOptionsSchema = z
181182
server_cdp_send_timeout_ms: z.number().positive().optional(),
182183
server_loopback_execution_context_timeout_ms: z.number().positive().optional(),
183184
server_ws_connect_error_settle_timeout_ms: z.number().positive().optional(),
185+
server_downstream_client_timeout_ms: z.number().positive().optional(),
186+
server_close_browser_on_downstream_disconnect: z.boolean().optional(),
184187
})
185188
.passthrough();
186189
export type ModCDPServerOptions = z.infer<typeof ModCDPServerOptionsSchema>;

0 commit comments

Comments
 (0)