mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
fix(camera): stop /camera/stop from letting a second socket open (#2521)
The single-connection barrier from the last round was correct and was being bypassed. shutdown_broadcaster() popped the broadcaster out of the registry and only then awaited its teardown, so while the socket was still closing the slot sat empty: a /camera/stream request landing in that window minted a broadcaster with no predecessor and dialled port 6000 immediately. A page reload fires /camera/stop and the new stream request concurrently, so a P1S ended up holding two connections, kept feeding the orphan, and starved the live viewer until its TCP keepalive reaped the dead one ~20 min later. The stopped broadcaster now stays in the registry so the successor chains behind its socket close. The camera page also rendered the <img> src before the stream token arrived whenever auth was disabled, then swapped it once the token landed — aborting the in-flight request and issuing a second one. With auth off both reached the backend, so every load attached two viewers to a one-socket printer. The src now waits for the token query to settle. Subscribers only checked for client disconnect after yielding a frame or on a 30s idle timeout, so a viewer that left during a black stream stayed counted — and /camera/stop trusts that count to decide whether to tear the upstream down.
This commit is contained in:
@@ -5,6 +5,9 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.5b2] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- **P1S camera still black on every page load, recovering only after ~20 minutes (#2521, reporter @nnimby848)** — The previous round of fixes did not take, and the reporter re-tested on two daily builds to say so. The fan-out barrier added last time — a replacement stream waits for the displaced one's socket to close before dialling, so a printer that allows a single camera connection never sees two at once — was correct, and was being **bypassed**. `shutdown_broadcaster()` *popped* the broadcaster out of the registry and only then awaited its teardown, so for the duration of the socket close the registry slot sat empty. A `/camera/stream` request landing in that window found nothing, minted a broadcaster with no predecessor to wait for, and dialled port 6000 immediately. The barrier only engages when the displaced broadcaster is still findable — and the one path that tears a stream down on purpose removed it first, disabling the barrier in exactly the case it was written for. A page reload fires `/camera/stop` and the new `/camera/stream` **concurrently**, which is why it reproduced on essentially every load. The printer then held two connections, kept feeding the orphan, and starved the live viewer: the new socket connects (the reporter's logs show `Chamber image: connected`) and then receives nothing until the printer's TCP keepalive reaps the dead one — **his 20 minutes, to the minute**. The stopped broadcaster now stays in the registry so the next viewer chains behind its socket close, which is what the barrier always intended. Pinned by a test that counts *actual* sockets through the real stop-then-restream race and fails with `2` against the old code; the existing barrier tests placed the broadcaster into the registry by hand, which is precisely why they never caught this.
|
||||
- **Every camera page load attached two viewers and abandoned one (#2521)** — Found while reproducing the above, and the reason it fired on *every* load rather than occasionally. The stream-token query runs whether or not authentication is enabled, and the camera page subscribes to it: the first render produced an `<img src>` with no token, the token arrived, and the re-render **changed the src**. The browser aborts the in-flight request and issues a second one — and with auth disabled no token is required, so *both* reached the backend and attached to the fan-out. The reporter's HAR shows it exactly: two requests to the same stream URL, same cache-buster, one without `token=` and one with. His backend log shows the consequence, `subscribers=2`, on a printer that allows one connection. The src is now rendered only once the token query has settled — one URL, one request, one viewer — and an auth-disabled install whose token endpoint fails still streams, because it never needed a token.
|
||||
- **A viewer that left during a black stream stayed counted for 30 seconds (#2521)** — Also found on the way. A subscriber only checked whether its client was still connected *after* it had yielded a frame, or when a 30-second idle timeout fired. So a browser that walked away while the stream was producing nothing — the exact situation above — went on being counted as an attached viewer for up to half a minute. That matters beyond tidiness: `/camera/stop` consults the subscriber count to decide whether to tear the upstream down, so a phantom viewer could make it skip the teardown entirely and leave the socket open. Disconnects are now noticed within a second even when no frames are flowing.
|
||||
- **"Please login." when importing from MakerWorld — while Bambuddy said you were connected to Bambu Cloud** — An expired Bambu Cloud token was indistinguishable from a working one, so the UI reported "Connected as ..." indefinitely while every cloud call was being rejected. The toast you got was Bambu Lab's own words, forwarded verbatim: their 401 body is `{"code":4,"error":"Please login.","message":""}`, and we passed the `error` field straight through — which read as Bambuddy telling you to log in, next to an indicator saying you already were. **The status was never real.** `set_token()` stamped `token_expiry = now + 30 days` *every time a stored token was loaded from the database* — re-derived from the current moment, for a token of entirely unknown age — and `is_authenticated` was "we have a string, and we're not past that expiry". The expiry reset on every request, so the check could never fail. It was a string-presence test wearing an expiry costume, and `/cloud/status` answered `true` for as long as any token existed. Bambu's access token is opaque (no readable claims), Bambu's login response carries no expiry, and Bambuddy discards the `refreshToken` it is handed, so nothing else in the system knew either. When a token lapsed — Bambu's own comment in our code says they last around three months — **every** cloud feature died at once (MakerWorld imports, cloud profiles, slicer presets, firmware checks) with no signal anywhere that a re-login was needed. **Bambu is now the authority.** No expiry is invented. `/cloud/status` asks Bambu whether the token is still accepted, cached for five minutes so the several components polling it don't each pay a round-trip, and any 401 from any authenticated cloud call durably records the credential as dead — so the whole app agrees at once instead of each feature failing separately. An unreachable Bambu, a 5xx, or a Cloudflare challenge is treated as *unknown*, never as *expired*: an outage must not sign a working session out. The Profiles page now explains why the login form is back, MakerWorld says the sign-in expired rather than that one is required, and its import buttons stop pretending they can download. The user-facing message names the **Profiles** page, where the Bambu Cloud sign-in actually lives — the old fallback text pointed at "Settings → Bambu Cloud", which does not exist.
|
||||
- **Importing from MakerWorld failed on Windows with a certificate error (#2562)** — Paste a MakerWorld URL, click Save, and the import dies with `S3 download failed: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate`. Only native Windows installs are affected; Docker never sees it. The import walks several hosts, and the failure is at the last hop: Bambu Cloud answers the download request with an **AWS presigned URL**, and that one URL is fetched with `urllib` rather than httpx, on purpose — S3 signs the exact query-string bytes, and httpx re-encodes them into a `SignatureDoesNotMatch`. What that swap quietly also changed was the **trust store**. httpx — every other network call in Bambuddy, including the `api.bambulab.com` calls that succeed immediately before this one — verifies against the bundled `certifi` CA bundle. `urllib` verifies against the *operating system's* store, and on Windows the two disagree: Python's `ssl.load_default_certs()` only enumerates the roots already cached in the Windows ROOT store, which Windows fills in lazily through CryptoAPI's auto-update — a mechanism Python never triggers. On a machine where the Amazon root signing the S3 chain has not been cached yet, verification fails with exactly the error above. Linux images ship a complete `ca-certificates` bundle, so the OS store and certifi agree and the bug is invisible there. The S3 hop now verifies against certifi too, so it trusts precisely what the rest of the app already trusts. Verification itself is untouched — the certificate is still checked and the hostname still matched; the fix changes where the CA list comes from, not whether TLS is enforced. The URL still reaches the transport byte-for-byte, so the S3 signature is unaffected, and the no-redirect guard that keeps the download-host allowlist meaningful is unchanged. `certifi` is now an explicit requirement rather than one inherited from httpx, so a future httpx release cannot drop it out from under this import.
|
||||
- **Prints on a multi-printer farm started one by one, up to an hour apart (#2555, reporter @Maxtrim3D)** — Start a batch across several printers and they trickle out one at a time; the more printers, the worse it gets. Not a misconfiguration, and nothing in the wiki could have helped: the scheduler awaited each dispatch inline in its selection loop, and a dispatch includes the FTP upload. So every printer queued behind every other printer's transfer, even though they are entirely independent machines. **The arithmetic is the whole bug report.** A Bambu printer's FTP server sustains around 150 KB/s — its own SD-card write is the bottleneck, not the network — so the reporter's 41 MB `.3mf` took **254 seconds per printer**, straight from his logs (`40978500 bytes in 254.1s, 157 KB/s`). Nineteen printers in series is roughly **80 minutes** before the last one starts, which is exactly the "up to 1 hour" he reported, and exactly why it got worse the more printers he selected — the delay is linear in fleet size. The logs show the next upload beginning **131 ms** after the previous one finished, back to back, forever. **Uploads to different printers now run concurrently**, capped by a new **Settings → Workflow → Queue & Dispatch → Concurrent Uploads** value (default 4, up to 16; set it to 1 for the old strictly-serial behaviour if your network or host cannot take parallel transfers). Selection is unchanged and still sequential — only the transfers overlap — so every existing gate (busy printers, plate-clear, filament deficit, shortest-job-first, staggered start) behaves exactly as before, and a queue pass still finishes all of its uploads before the next one begins, which is what stops the same still-`pending` row being dispatched twice. FTP work also moves off asyncio's shared default executor onto its own pool: that executor is sized `min(32, cpu_count + 4)` — six threads on a 2-core NAS — and is shared with everything else in the app, so parallel uploads would have parked one thread each, for minutes at a time, and starved unrelated work.
|
||||
|
||||
@@ -40,6 +40,13 @@ _SUBSCRIBER_QUEUE_SIZE = 4
|
||||
# subscriber's read loop can break out cleanly instead of hanging on get().
|
||||
_UPSTREAM_GONE = b""
|
||||
|
||||
# How often a subscriber that isn't receiving frames re-checks whether its
|
||||
# client is still connected. Only pays a cost when the stream is *not* producing
|
||||
# frames — the normal path returns from queue.get() as soon as a frame lands and
|
||||
# checks after the yield. Kept short because the subscriber count derived from
|
||||
# it is what /camera/stop uses to decide whether to tear the upstream down.
|
||||
_DISCONNECT_POLL_SECONDS = 1.0
|
||||
|
||||
UpstreamFactory = Callable[[asyncio.Event], AsyncGenerator[bytes, None]]
|
||||
|
||||
|
||||
@@ -279,11 +286,30 @@ async def get_or_create_broadcaster(key: str, factory: UpstreamFactory) -> Mjpeg
|
||||
|
||||
|
||||
async def shutdown_broadcaster(key: str) -> bool:
|
||||
"""Force-shutdown the broadcaster for `key`. Returns True if one was running."""
|
||||
"""Force-shutdown the broadcaster for `key`. Returns True if one was running.
|
||||
|
||||
The stopped broadcaster stays in the registry on purpose. It used to be
|
||||
popped *before* ``force_shutdown()`` was awaited, which vacated the slot
|
||||
while the upstream socket was still closing: a ``/camera/stream`` request
|
||||
landing in that window found nothing, minted a broadcaster with
|
||||
``predecessor=None``, and dialled the printer immediately. That is exactly
|
||||
the two-sockets-at-once overlap the predecessor gate exists to prevent —
|
||||
the gate only engages when the stopped broadcaster is still *findable*, and
|
||||
popping it here bypassed the gate in the one case it was written for. A page
|
||||
reload fires ``/camera/stop`` and the new stream request concurrently, so a
|
||||
single-connection cam (chamber-image port 6000) ended up with an orphaned
|
||||
socket that the printer kept feeding, starving the live viewer until the
|
||||
printer's TCP keepalive reaped it ~20 min later (#2521).
|
||||
|
||||
Leaving it in place is safe: ``get_or_create_broadcaster`` replaces a stopped
|
||||
entry (chaining the successor behind its teardown), ``get_subscriber_count``
|
||||
reports 0 for it, and ``active_broadcaster_keys`` filters it out. There is at
|
||||
most one entry per printer, and it is overwritten by the next viewer.
|
||||
"""
|
||||
async with _registry_lock:
|
||||
bc = _broadcasters.pop(key, None)
|
||||
if bc is None:
|
||||
return False
|
||||
bc = _broadcasters.get(key)
|
||||
if bc is None or bc.stopped:
|
||||
return False
|
||||
await bc.force_shutdown()
|
||||
return True
|
||||
|
||||
@@ -338,10 +364,16 @@ async def iter_subscriber(
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
chunk = await asyncio.wait_for(queue.get(), timeout=_DISCONNECT_POLL_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
# No frame in 30s — check whether the client is still there.
|
||||
# If yes, keep waiting; if no, bail out.
|
||||
# No frame this tick — is the client still there? This used to
|
||||
# wait 30 s before asking, and the disconnect check after a yield
|
||||
# only fires when frames are actually flowing. So a viewer that
|
||||
# went away while the stream was black stayed *counted* as a
|
||||
# subscriber for up to half a minute — and ``/camera/stop``
|
||||
# trusts that count to decide whether to tear the upstream down,
|
||||
# so a phantom subscriber could make it skip teardown entirely
|
||||
# (#2521). Poll often enough that the count means something.
|
||||
if is_disconnected is not None and await is_disconnected():
|
||||
break
|
||||
continue
|
||||
|
||||
@@ -445,3 +445,142 @@ async def test_successor_pump_times_out_if_predecessor_wedges(monkeypatch):
|
||||
# After the bounded wait elapses the pump dials and delivers a frame.
|
||||
assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"x"
|
||||
await shutdown_broadcaster("p1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The printer only has ONE camera socket (#2521)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _socket_counting_factory(state: dict, *, close_delay: float = 0.05):
|
||||
"""Upstream factory that models a real TCP socket to the printer.
|
||||
|
||||
Records the peak number of simultaneously-open sockets. A chamber-image cam
|
||||
(P1/A1, port 6000) accepts exactly one connection: when a second overlaps,
|
||||
the printer keeps feeding the first and the newcomer never sees a frame —
|
||||
until the printer's TCP keepalive reaps the orphan, ~20 minutes later.
|
||||
"""
|
||||
|
||||
async def factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
|
||||
await asyncio.sleep(0.01) # dial + TLS handshake
|
||||
state["open"] += 1
|
||||
state["peak"] = max(state["peak"], state["open"])
|
||||
try:
|
||||
while not disconnect.is_set():
|
||||
await asyncio.sleep(0.01)
|
||||
yield b"frame"
|
||||
finally:
|
||||
await asyncio.sleep(close_delay) # TCP close is not instantaneous
|
||||
state["open"] -= 1
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
async def test_stop_then_restream_never_opens_two_sockets():
|
||||
"""A page reload fires POST /camera/stop and GET /camera/stream at the same
|
||||
time. ``shutdown_broadcaster`` used to *pop* the broadcaster out of the
|
||||
registry and only then await its teardown, so a stream request landing in
|
||||
that window found an empty slot, minted a broadcaster with no predecessor,
|
||||
and dialled the printer while the old socket was still closing (#2521).
|
||||
"""
|
||||
state = {"open": 0, "peak": 0}
|
||||
factory = _socket_counting_factory(state)
|
||||
|
||||
bc1 = await get_or_create_broadcaster("p1", factory)
|
||||
queue = await bc1.subscribe()
|
||||
assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"frame"
|
||||
await bc1.unsubscribe(queue)
|
||||
|
||||
async def viewer_unmount_stop():
|
||||
await shutdown_broadcaster("p1")
|
||||
|
||||
async def reloaded_page_streams():
|
||||
await asyncio.sleep(0.005) # lands a hair after the stop
|
||||
bc = await get_or_create_broadcaster("p1", factory)
|
||||
q = await bc.subscribe()
|
||||
return await asyncio.wait_for(q.get(), timeout=2.0)
|
||||
|
||||
_stop_result, frame = await asyncio.gather(viewer_unmount_stop(), reloaded_page_streams())
|
||||
|
||||
assert frame == b"frame", "the reloaded page's viewer never received a frame"
|
||||
assert state["peak"] == 1, (
|
||||
f"opened {state['peak']} concurrent sockets to a printer that allows one — "
|
||||
"the new stream dialled before the old socket closed"
|
||||
)
|
||||
await shutdown_broadcaster("p1")
|
||||
|
||||
|
||||
async def test_shutdown_broadcaster_leaves_a_chainable_predecessor():
|
||||
"""The stopped broadcaster must stay findable in the registry: that is what
|
||||
lets the next viewer's pump chain behind its socket close."""
|
||||
state = {"open": 0, "peak": 0}
|
||||
factory = _socket_counting_factory(state)
|
||||
|
||||
bc1 = await get_or_create_broadcaster("p1", factory)
|
||||
await bc1.subscribe()
|
||||
await shutdown_broadcaster("p1")
|
||||
|
||||
assert camera_fanout._broadcasters.get("p1") is bc1, ( # noqa: SLF001
|
||||
"the stopped broadcaster was removed from the registry — a successor "
|
||||
"created now would have predecessor=None and dial immediately"
|
||||
)
|
||||
bc2 = await get_or_create_broadcaster("p1", factory)
|
||||
assert bc2._predecessor is bc1 # noqa: SLF001 — white-box: the chain is the fix
|
||||
await shutdown_broadcaster("p1")
|
||||
|
||||
|
||||
async def test_shutdown_broadcaster_is_idempotent():
|
||||
"""/camera/stop can fire twice (unmount + beforeunload). The second call
|
||||
must report nothing was running rather than tearing down a live successor."""
|
||||
factory = _make_factory([b"x"] * 1000, delay=0.02)
|
||||
bc = await get_or_create_broadcaster("p1", factory)
|
||||
await bc.subscribe()
|
||||
|
||||
assert await shutdown_broadcaster("p1") is True
|
||||
assert await shutdown_broadcaster("p1") is False
|
||||
assert await shutdown_broadcaster("never-existed") is False
|
||||
|
||||
|
||||
async def test_stopped_broadcaster_reports_no_subscribers():
|
||||
"""/camera/stop's reference-count guard must not see the corpse's leftovers."""
|
||||
from backend.app.services.camera_fanout import get_subscriber_count
|
||||
|
||||
factory = _make_factory([b"x"] * 1000, delay=0.02)
|
||||
bc = await get_or_create_broadcaster("p1", factory)
|
||||
await bc.subscribe()
|
||||
assert get_subscriber_count("p1") == 1
|
||||
|
||||
await shutdown_broadcaster("p1")
|
||||
assert get_subscriber_count("p1") == 0, "a stopped broadcaster still reported subscribers"
|
||||
|
||||
|
||||
async def test_subscriber_with_no_frames_detaches_promptly():
|
||||
"""A viewer that goes away while the stream is black must stop being counted.
|
||||
|
||||
The disconnect check only ran after a chunk was yielded, or on a 30 s idle
|
||||
timeout — so a client that left during a black stream stayed *counted* as a
|
||||
subscriber for up to half a minute. /camera/stop trusts that count to decide
|
||||
whether to tear the upstream down, so a phantom subscriber could make it
|
||||
skip teardown entirely (#2521).
|
||||
"""
|
||||
|
||||
async def silent_factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
|
||||
await disconnect.wait() # connected, but the printer sends nothing
|
||||
return
|
||||
yield # pragma: no cover — makes this an async generator
|
||||
|
||||
bc = MjpegBroadcaster("p1", silent_factory)
|
||||
queue = await bc.subscribe()
|
||||
assert bc.subscriber_count == 1
|
||||
|
||||
async def is_disconnected() -> bool:
|
||||
return True # the browser aborted the request
|
||||
|
||||
async def drain():
|
||||
async for _chunk in iter_subscriber(bc, queue, is_disconnected=is_disconnected):
|
||||
pass
|
||||
|
||||
# Must notice well inside the old 30 s idle timeout.
|
||||
await asyncio.wait_for(drain(), timeout=3.0)
|
||||
assert bc.subscriber_count == 0
|
||||
await bc.force_shutdown()
|
||||
|
||||
@@ -177,13 +177,58 @@ describe('CameraPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders image src immediately when auth is disabled (no token required)', async () => {
|
||||
it('does not fire a tokenless stream request when auth is disabled (#2521)', async () => {
|
||||
// The stream-token query runs whether or not auth is enabled, and this page
|
||||
// subscribes to it. So the src used to be rendered on the first pass with no
|
||||
// token, and swapped once the token landed. Changing img.src makes the
|
||||
// browser abort the in-flight request and issue a second one — and with auth
|
||||
// disabled no token is required, so BOTH reached the backend and attached to
|
||||
// the camera fan-out. Every page load put two viewers on a printer that
|
||||
// allows one socket, then abandoned one of them.
|
||||
let resolveToken!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resolveToken = resolve;
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post('*/api/v1/printers/camera/stream-token', async () => {
|
||||
await gate;
|
||||
return HttpResponse.json({ token: 'tok-xyz' });
|
||||
})
|
||||
);
|
||||
|
||||
renderCameraPage(1);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Token still in flight: the <img> must not already be pulling the stream.
|
||||
const early = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
|
||||
expect(early).not.toContain('/camera/stream');
|
||||
|
||||
resolveToken();
|
||||
|
||||
// The first — and only — stream URL the browser ever sees is the tokened one.
|
||||
await waitFor(() => {
|
||||
const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
|
||||
expect(src).toContain('/api/v1/printers/1/camera/stream');
|
||||
expect(src).toContain('token=tok-xyz');
|
||||
});
|
||||
});
|
||||
|
||||
it('still streams when auth is disabled and the token endpoint fails', async () => {
|
||||
// Waiting for the token must not become a way to never render at all: an
|
||||
// auth-disabled backend doesn't need one. Once the query settles — even
|
||||
// unsuccessfully — the stream loads.
|
||||
server.use(
|
||||
http.post('*/api/v1/printers/camera/stream-token', () => new HttpResponse(null, { status: 500 }))
|
||||
);
|
||||
|
||||
renderCameraPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
|
||||
expect(src).toContain(`/api/v1/printers/1/camera/stream`);
|
||||
expect(src).not.toContain('token=');
|
||||
expect(src).toContain('/api/v1/printers/1/camera/stream');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CameraPage() {
|
||||
// arrives. useStreamTokenSync (mounted in App) already owns the fetch; this
|
||||
// useQuery call dedupes via the shared key and just reads the cached value.
|
||||
useStreamTokenSync();
|
||||
const { data: streamTokenData } = useQuery({
|
||||
const { data: streamTokenData, isPending: streamTokenPending } = useQuery({
|
||||
queryKey: ['camera-stream-token', user?.id ?? null],
|
||||
queryFn: () => api.getCameraStreamToken(),
|
||||
enabled: authEnabled ? !!user : true,
|
||||
@@ -617,7 +617,17 @@ export function CameraPage() {
|
||||
// the token directly from the reactive query value instead of relying on the
|
||||
// module-level cache in withStreamToken(), because that cache is updated in a
|
||||
// useEffect that runs after render.
|
||||
const waitingForStreamToken = authEnabled && !streamTokenValue;
|
||||
//
|
||||
// We also wait when auth is *disabled* (#2521). The token query runs either
|
||||
// way, and this page subscribes to it — so the first render produced a src
|
||||
// with no token, the token landed, and the re-render CHANGED img.src. The
|
||||
// browser aborts the in-flight request and issues a second one. With auth off
|
||||
// no token is required, so *both* reached the backend and attached to the
|
||||
// fan-out: every page load added two viewers and abandoned one of them. Wait
|
||||
// for the query to settle and there is one src, one request, one viewer.
|
||||
// Falling through once it has settled without a token keeps an auth-disabled
|
||||
// install working even if the token endpoint fails — it doesn't need one.
|
||||
const waitingForStreamToken = !streamTokenValue && (authEnabled || streamTokenPending);
|
||||
const appendToken = (url: string) =>
|
||||
streamTokenValue ? `${url}&token=${encodeURIComponent(streamTokenValue)}` : withStreamToken(url);
|
||||
const currentUrl = transitioning || waitingForStreamToken
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-B_yurhVI.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C-4pTpBI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-4NXlsp1C.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user