Skip to content

Commit ff16822

Browse files
committed
fix: http/1.1 limit on long polling connections prevents >3 tabs from receiving operation streams
1 parent 4d675d1 commit ff16822

8 files changed

Lines changed: 906 additions & 274 deletions

File tree

webui/src/api/logState.ts

Lines changed: 20 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
Operation,
3-
OperationEvent,
43
OperationEventType,
54
OperationStatus,
65
} from "../../gen/ts/v1/operations_pb";
@@ -9,11 +8,7 @@ import {
98
GetOperationsRequestSchema,
109
OpSelector,
1110
} from "../../gen/ts/v1/service_pb";
12-
import {
13-
getOperations,
14-
subscribeToOperations,
15-
unsubscribeFromOperations,
16-
} from "./oplog";
11+
import { getOperations, operationsStream } from "./oplog";
1712
import { create } from "@bufbuild/protobuf";
1813

1914
type Subscriber = (
@@ -28,35 +23,23 @@ export const syncStateFromRequest = (
2823
onError?: (e: Error) => void,
2924
onInitialLoad?: () => void,
3025
): (() => void) => {
31-
getOperations(req)
32-
.then((res) => {
33-
state.add(...res);
34-
onInitialLoad?.();
35-
})
36-
.catch((e) => {
37-
if (onError) {
38-
onError(e);
39-
}
40-
onInitialLoad?.();
41-
});
42-
43-
const cbHelper = (event?: OperationEvent, err?: Error) => {
44-
if (err) {
45-
if (onError) {
46-
onError(err);
47-
}
48-
state.reset();
26+
// The stream carries only deltas, which may have gaps across a connect or
27+
// resync, so (re)load the full set from the API and rebuild from scratch.
28+
const load = () => {
29+
state.reset();
30+
getOperations(req)
31+
.then((res) => {
32+
state.add(...res);
33+
onInitialLoad?.();
34+
})
35+
.catch((e) => {
36+
onError?.(e);
37+
onInitialLoad?.();
38+
});
39+
};
4940

50-
getOperations(req)
51-
.then((res) => {
52-
state.add(...res);
53-
})
54-
.catch((e) => {
55-
if (onError) {
56-
onError(e);
57-
}
58-
});
59-
} else if (event) {
41+
return operationsStream.subscribe({
42+
onMessage: (event) => {
6043
switch (event.event.case) {
6144
case "createdOperations":
6245
case "updatedOperations":
@@ -70,13 +53,9 @@ export const syncStateFromRequest = (
7053
state.removeIDs(...event.event.value.values);
7154
break;
7255
}
73-
}
74-
};
75-
76-
subscribeToOperations(cbHelper);
77-
return () => {
78-
unsubscribeFromOperations(cbHelper);
79-
};
56+
},
57+
onConnectOrResync: load,
58+
});
8059
};
8160

8261
// getStatus returns the status of the last N operations that belong to a single snapshot.

webui/src/api/oplog.ts

Lines changed: 12 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,22 @@
11
import {
22
Operation,
33
OperationEvent,
4-
OperationEventType,
4+
OperationEventSchema,
55
OperationStatus,
66
} from "../../gen/ts/v1/operations_pb";
7-
import { GetOperationsRequest, OpSelector } from "../../gen/ts/v1/service_pb";
8-
import {
9-
BackupProgressEntry,
10-
ResticSnapshot,
11-
RestoreProgressEntry,
12-
} from "../../gen/ts/v1/restic_pb";
13-
import { EmptySchema } from "../../gen/ts/types/value_pb";
14-
import { create } from "@bufbuild/protobuf";
7+
import { GetOperationsRequest } from "../../gen/ts/v1/service_pb";
8+
import { fromBinary, toBinary } from "@bufbuild/protobuf";
159
import { backrestService } from "./client";
16-
import { useEffect, useState } from "react";
17-
18-
const subscribers: ((event?: OperationEvent, err?: Error) => void)[] = [];
10+
import { createSharedStream } from "./streams/sharedStream";
1911

20-
// Start fetching and emitting operations.
21-
(async () => {
22-
while (true) {
23-
let nextConnWaitUntil = new Date().getTime() + 5000;
24-
try {
25-
for await (const event of backrestService.getOperationEvents({})) {
26-
console.log("operation event", event);
27-
subscribers.forEach((subscriber) => subscriber(event, undefined));
28-
}
29-
} catch (e: any) {
30-
console.warn("operations stream died with exception: ", e);
31-
let waitRemaining = nextConnWaitUntil - new Date().getTime();
32-
if (waitRemaining < 0) {
33-
subscribers.forEach((subscriber) =>
34-
subscriber(undefined, e instanceof Error ? e : new Error(String(e))),
35-
);
36-
}
37-
}
38-
await new Promise((accept, _) =>
39-
setTimeout(accept, nextConnWaitUntil - new Date().getTime()),
40-
);
41-
}
42-
})();
12+
// Operation-event stream, shared across tabs (see sharedStream). onResync means
13+
// reset + refetch; consumers load their own initial state via getOperations().
14+
export const operationsStream = createSharedStream<OperationEvent>({
15+
name: "backrest:operations",
16+
connect: (signal) => backrestService.getOperationEvents({}, { signal }),
17+
encode: (event) => toBinary(OperationEventSchema, event),
18+
decode: (bytes) => fromBinary(OperationEventSchema, bytes),
19+
});
4320

4421
export const getOperations = async (
4522
req: GetOperationsRequest,
@@ -48,30 +25,6 @@ export const getOperations = async (
4825
return opList.operations || [];
4926
};
5027

51-
export const subscribeToOperations = (
52-
callback: (event?: OperationEvent, err?: Error) => void,
53-
) => {
54-
subscribers.push(callback);
55-
console.log(
56-
"subscribed to operations, subscriber count: ",
57-
subscribers.length,
58-
);
59-
};
60-
61-
export const unsubscribeFromOperations = (
62-
callback: (event?: OperationEvent, err?: Error) => void,
63-
) => {
64-
const index = subscribers.indexOf(callback);
65-
if (index > -1) {
66-
subscribers[index] = subscribers[subscribers.length - 1];
67-
subscribers.pop();
68-
}
69-
console.log(
70-
"unsubscribed from operations, subscriber count: ",
71-
subscribers.length,
72-
);
73-
};
74-
7528
export const shouldHideOperation = (operation: Operation) => {
7629
// Hide successful backups with no snapshot ID (e.g., --skip-if-unchanged)
7730
// but NOT dry run backups which intentionally have no snapshot

webui/src/api/resourceStatus.tsx

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
11
import { useEffect, useState } from "react";
2-
import {
3-
Operation,
4-
OperationEvent,
5-
OperationStatus,
6-
} from "../../gen/ts/v1/operations_pb";
2+
import { Operation, OperationStatus } from "../../gen/ts/v1/operations_pb";
73
import { OpSelector } from "../../gen/ts/v1/service_pb";
8-
import { subscribeToOperations, unsubscribeFromOperations } from "./oplog";
4+
import { operationsStream } from "./oplog";
95
import { getStatusForSelector, matchSelector } from "./logState";
106
import { debounce } from "../lib/util";
117

128
// Module-level shared state: all registered selectors and their cached statuses.
139
const selectors = new Map<string, { selector: OpSelector; refCount: number }>();
1410
const statuses = new Map<string, OperationStatus>();
1511
const listeners = new Set<() => void>();
16-
let subscribed = false;
12+
let unsubscribe: (() => void) | null = null;
1713

1814
const notify = () => {
1915
for (const l of listeners) l();
@@ -62,19 +58,6 @@ const refreshMatching = (ops: Operation[]) => {
6258
}
6359
};
6460

65-
const handleEvent = (event?: OperationEvent, _err?: Error) => {
66-
if (!event || !event.event) return;
67-
switch (event.event.case) {
68-
case "createdOperations":
69-
case "updatedOperations":
70-
refreshMatching(event.event.value.operations);
71-
break;
72-
case "deletedOperations":
73-
refreshAll();
74-
break;
75-
}
76-
};
77-
7861
const register = (selector: OpSelector): string => {
7962
const key = JSON.stringify(selector);
8063
const existing = selectors.get(key);
@@ -84,9 +67,22 @@ const register = (selector: OpSelector): string => {
8467
}
8568
selectors.set(key, { selector, refCount: 1 });
8669
fetchStatus(key, selector);
87-
if (!subscribed) {
88-
subscribed = true;
89-
subscribeToOperations(handleEvent);
70+
if (!unsubscribe) {
71+
unsubscribe = operationsStream.subscribe({
72+
onMessage: (event) => {
73+
switch (event.event.case) {
74+
case "createdOperations":
75+
case "updatedOperations":
76+
refreshMatching(event.event.value.operations);
77+
break;
78+
case "deletedOperations":
79+
refreshAll();
80+
break;
81+
}
82+
},
83+
// May have missed events; re-fetch every tracked selector.
84+
onConnectOrResync: () => refreshAll(),
85+
});
9086
}
9187
return key;
9288
};
@@ -99,9 +95,9 @@ const unregister = (key: string) => {
9995
selectors.delete(key);
10096
statuses.delete(key);
10197
notify();
102-
if (selectors.size === 0 && subscribed) {
103-
subscribed = false;
104-
unsubscribeFromOperations(handleEvent);
98+
if (selectors.size === 0 && unsubscribe) {
99+
unsubscribe();
100+
unsubscribe = null;
105101
flushPending.cancel();
106102
pendingKeys.clear();
107103
}

0 commit comments

Comments
 (0)