poller and release on end to frontend

This commit is contained in:
user
2026-03-28 20:10:44 +02:00
parent e525c657ac
commit 3e018d60f9
7 changed files with 196 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
import { resolveLinkSQ } from "./link.remote";
import { checkLinkValiditySQ, resolveLinkSQ } from "./link.remote";
import type { Err } from "@pkg/result";
export type SessionEndReason =
@@ -22,6 +22,17 @@ export async function resolveLinkFresh(token: string) {
return await query;
}
export async function checkLinkValidityFresh(token: string) {
const query = checkLinkValiditySQ({ token });
if (query.ready) {
await query.refresh();
return query.current;
}
return await query;
}
export function mapLinkErrorToSessionEndReason(
error?: Pick<Err, "code" | "message" | "description"> | null,
): SessionEndReason {

View File

@@ -1,6 +1,11 @@
import { getFlowExecCtxForRemoteFuncs } from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import { prepareLinkFlow, resolveLinkFlow } from "./service";
import {
checkLinkValidityFlow,
endLinkSessionFlow,
prepareLinkFlow,
resolveLinkFlow,
} from "./service";
import { tokenSchema } from "./data";
export const resolveLinkSQ = query(tokenSchema, async ({ token }) => {
@@ -14,3 +19,15 @@ export const prepareLinkSC = command(tokenSchema, async ({ token }) => {
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
return prepareLinkFlow(fctx, token);
});
export const checkLinkValiditySQ = query(tokenSchema, async ({ token }) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
return checkLinkValidityFlow(fctx, token);
});
export const endLinkSessionSC = command(tokenSchema, async ({ token }) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
return endLinkSessionFlow(fctx, token);
});

View File

@@ -13,8 +13,10 @@ import { DeviceStatus } from "@pkg/logic/domains/device/data";
import { ERROR_CODES, type Err } from "@pkg/result";
import { settings } from "@pkg/settings";
import { WS_SCRCPY_URL } from "$lib/core/constants";
import { getDeviceController } from "@pkg/logic/domains/device/controller";
const lc = getLinkController();
const dc = getDeviceController();
type LinkSessionShape = {
id: number;
@@ -243,3 +245,97 @@ export async function prepareLinkFlow(fctx: FlowExecCtx, token: string) {
};
}
}
export async function checkLinkValidityFlow(fctx: FlowExecCtx, token: string) {
logDomainEvent({
event: "front.link_validity_check.started",
fctx,
meta: { token },
});
const linkResult = await lc.validateReadOnly(fctx, token);
if (linkResult.isErr()) {
logDomainEvent({
level: "warn",
event: "front.link_validity_check.rejected",
fctx,
error: linkResult.error,
meta: { token },
});
return { data: null, error: linkResult.error };
}
const link = linkResult.value;
const streamUrl = link.device
? buildStreamUrl(link.device.host, link.device.wsPort)
: null;
return {
data: {
link: {
id: link.id,
token: link.token,
status: link.status,
expiresAt: link.expiresAt,
},
device: link.device
? {
id: link.device.id,
streamUrl,
}
: null,
},
error: null,
};
}
export async function endLinkSessionFlow(fctx: FlowExecCtx, token: string) {
logDomainEvent({
event: "front.link_session_end.started",
fctx,
meta: { token },
});
const linkResult = await lc.getByToken(fctx, token);
if (linkResult.isErr()) {
logDomainEvent({
level: "warn",
event: "front.link_session_end.link_lookup_failed",
fctx,
error: linkResult.error,
meta: { token },
});
return { data: null, error: linkResult.error };
}
const linkedDeviceId = linkResult.value.linkedDeviceId;
if (linkedDeviceId === null) {
return {
data: { released: false, reason: "no_linked_device" as const },
error: null,
};
}
const releaseResult = await dc.release(fctx, linkedDeviceId);
if (releaseResult.isErr()) {
logDomainEvent({
level: "error",
event: "front.link_session_end.release_failed",
fctx,
error: releaseResult.error,
meta: {
token,
deviceId: linkedDeviceId,
},
});
return { data: null, error: releaseResult.error };
}
return {
data: { released: true, deviceId: linkedDeviceId },
error: null,
};
}

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
checkLinkValidityFresh,
mapLinkErrorToSessionEndReason,
resolveLinkFresh,
} from "$lib/domains/link/client";
import { endLinkSessionSC } from "$lib/domains/link/link.remote";
import { onMount } from "svelte";
const POLL_INTERVAL_MS = 2000;
@@ -16,14 +17,26 @@
onMount(() => {
let disposed = false;
let hasEnded = false;
let timer: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
let consecutivePollErrors = 0;
const endSession = async (reason: string) => {
if (disposed) return;
if (disposed || hasEnded) return;
hasEnded = true;
if (timer) clearInterval(timer);
streamUrl = null;
if (token) {
try {
await endLinkSessionSC({ token });
} catch {
// Best-effort cleanup; user ejection should proceed regardless.
}
}
await goto(`/session-ended?reason=${encodeURIComponent(reason)}`, {
replaceState: true,
});
@@ -34,7 +47,7 @@
inFlight = true;
try {
const result = await resolveLinkFresh(token);
const result = await checkLinkValidityFresh(token);
if (disposed) return;
@@ -52,7 +65,7 @@
return;
}
const nextStreamUrl = result.data.device.streamUrl;
const nextStreamUrl = result.data.device?.streamUrl;
if (typeof nextStreamUrl === "string" && nextStreamUrl.length > 0) {
streamUrl = nextStreamUrl;
}
@@ -79,13 +92,13 @@
return;
}
const initial = await resolveLinkFresh(token);
const initial = await checkLinkValidityFresh(token);
if (initial?.error || !initial?.data) {
await endSession(mapLinkErrorToSessionEndReason(initial?.error));
return;
}
streamUrl = initial.data.device.streamUrl;
streamUrl = initial.data.device?.streamUrl ?? null;
state = "ready";
statusText = "Session active";
@@ -100,6 +113,9 @@
return () => {
disposed = true;
if (timer) clearInterval(timer);
if (!hasEnded && token) {
void endLinkSessionSC({ token });
}
};
});
</script>

View File

@@ -1,10 +1,12 @@
import { getLinkController } from "@pkg/logic/domains/link/controller";
import { createLinkSchema, updateLinkSchema } from "@pkg/logic/domains/link/data";
import { getDeviceController } from "@pkg/logic/domains/device/controller";
import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const lc = getLinkController();
const dc = getDeviceController();
export const listLinksSQ = query(async () => {
const event = getRequestEvent();
@@ -66,10 +68,25 @@ export const revokeLinkSC = command(
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await lc.revoke(fctx, payload.id);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
const linkResult = await lc.getById(fctx, payload.id);
if (linkResult.isErr()) {
return { data: null, error: linkResult.error };
}
const revokeResult = await lc.revoke(fctx, payload.id);
if (revokeResult.isErr()) {
return { data: null, error: revokeResult.error };
}
const linkedDeviceId = linkResult.value.linkedDeviceId;
if (linkedDeviceId !== null) {
const releaseResult = await dc.release(fctx, linkedDeviceId);
if (releaseResult.isErr()) {
return { data: null, error: releaseResult.error };
}
}
return { data: revokeResult.value, error: null };
},
);

View File

@@ -95,3 +95,10 @@ Update rule:
- Added `adb` installation to `dockerfiles/orchestrator.Dockerfile` so session-prepare no longer fails with `spawn adb ENOENT` in production containers.
- Kept runtime behavior unchanged otherwise; this is strictly a missing binary/runtime dependency fix.
### 14 — Session Safety: No-Touch Polling + Release Hooks
- Added read-only link validation in logic (`validateReadOnly`) and wired a dedicated frontend validity query for high-frequency polling without updating `lastAccessedAt`.
- Updated frontend session polling to use the new no-touch validity path and avoid stale query cache via forced refresh behavior.
- Added frontend session end command to explicitly release the linked device on session teardown/ejection.
- Updated admin link revoke flow to release the linked device after revocation so revoked sessions do not leave devices stuck `inUse`.

View File

@@ -6,7 +6,7 @@ import {
UpdateLink,
} from "./data";
import { FlowExecCtx } from "@core/flow.execution.context";
import { errAsync, ResultAsync } from "neverthrow";
import { errAsync, okAsync, ResultAsync } from "neverthrow";
import { LinkRepository } from "./repository";
import { type Err } from "@pkg/result";
import { linkErrors } from "./errors";
@@ -54,6 +54,25 @@ export class LinkController {
});
}
/**
* Validate a token without updating lastAccessedAt.
* Useful for high-frequency polling paths.
*/
validateReadOnly(
fctx: FlowExecCtx,
token: string,
): ResultAsync<LinkWithDevice, Err> {
return this.repo.getByToken(fctx, token).andThen((l) => {
if (l.status !== LinkStatus.ACTIVE) {
return errAsync(linkErrors.linkNotActive(fctx, token));
}
if (l.expiresAt && l.expiresAt < new Date()) {
return errAsync(linkErrors.linkExpired(fctx, token));
}
return okAsync(l);
});
}
/**
* Generate a new link. Token is auto-generated as a URL-safe nanoid.
*/