mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(dsh): synchronize observation state and manage thumbnail capture
This commit is contained in:
@@ -49,8 +49,9 @@ export interface SessionObservation {
|
||||
dshSessionIds?: string[];
|
||||
}
|
||||
|
||||
/** Incremental event carried to subscribers (SSE on the wire). */
|
||||
/** Initial snapshot or incremental change carried to subscribers (SSE on the wire). */
|
||||
export type ObservationEvent =
|
||||
| { type: "snapshot"; sessions: SessionObservation[]; available: boolean }
|
||||
| { type: "upsert" | "remove" | "reset"; session?: SessionObservation }
|
||||
| { type: "availability"; available: boolean };
|
||||
|
||||
@@ -89,6 +90,7 @@ export class ObservationService {
|
||||
private readonly scratchNamespace = randomUUID();
|
||||
private readonly observations = new Map<string, SessionObservation>();
|
||||
private readonly listeners = new Set<(event: ObservationEvent) => void>();
|
||||
private thumbnailViewers = 0;
|
||||
private readonly captureTimers = new Map<string, unknown>();
|
||||
private readonly captureInFlight = new Set<string>();
|
||||
/** Captures intentionally cancelled to make way for foreground work. */
|
||||
@@ -141,10 +143,30 @@ export class ObservationService {
|
||||
this.emit({ type: "availability", available });
|
||||
}
|
||||
|
||||
/** Subscribe to incremental changes; returns an unsubscribe function. */
|
||||
subscribe(listener: (event: ObservationEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
/** A snapshot and subsequent changes share one ordered subscription. */
|
||||
subscribe(
|
||||
listener: (event: ObservationEvent) => void,
|
||||
{ thumbnails = true }: { thumbnails?: boolean } = {},
|
||||
): () => void {
|
||||
if (this.disposed) return () => {};
|
||||
// Each subscription owns its lease, even if a caller reuses a callback.
|
||||
const receive = (event: ObservationEvent) => listener(event);
|
||||
this.listeners.add(receive);
|
||||
try {
|
||||
listener({ type: "snapshot", sessions: this.getState(), available: this.available });
|
||||
} catch (error) {
|
||||
this.listeners.delete(receive);
|
||||
throw error;
|
||||
}
|
||||
if (thumbnails && ++this.thumbnailViewers === 1) {
|
||||
for (const sessionId of this.observations.keys()) this.scheduleCapture(sessionId, 0);
|
||||
}
|
||||
return () => {
|
||||
if (!this.listeners.delete(receive)) return;
|
||||
if (thumbnails && --this.thumbnailViewers === 0) {
|
||||
for (const sessionId of this.captureTimers.keys()) this.cancelCapture(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private emit(event: ObservationEvent): void {
|
||||
@@ -355,6 +377,7 @@ export class ObservationService {
|
||||
this.observations.clear();
|
||||
this.emit({ type: "reset" });
|
||||
this.listeners.clear();
|
||||
this.thumbnailViewers = 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -369,12 +392,21 @@ export class ObservationService {
|
||||
}
|
||||
}
|
||||
|
||||
private canCapture(sessionId: string): boolean {
|
||||
const entry = this.observations.get(sessionId);
|
||||
return (
|
||||
this.deps.options.enabled &&
|
||||
!this.disposed &&
|
||||
this.thumbnailViewers > 0 &&
|
||||
!this.foregroundDepth.has(sessionId) &&
|
||||
entry !== undefined &&
|
||||
entry.dead !== true
|
||||
);
|
||||
}
|
||||
|
||||
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
|
||||
private scheduleCapture(sessionId: string, delayMs?: number): void {
|
||||
if (!this.deps.options.enabled || this.disposed) return;
|
||||
if (this.foregroundDepth.has(sessionId)) return;
|
||||
const entry = this.observations.get(sessionId);
|
||||
if (entry === undefined || entry.dead === true) return;
|
||||
if (!this.canCapture(sessionId)) return;
|
||||
this.cancelCapture(sessionId);
|
||||
const now = this.scheduler.now();
|
||||
const lastSeen = this.lastActivity.get(sessionId) ?? 0;
|
||||
@@ -399,9 +431,7 @@ export class ObservationService {
|
||||
* Failures keep the previous frame and back off silently.
|
||||
*/
|
||||
private async capture(sessionId: string): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
const current = this.observations.get(sessionId);
|
||||
if (current === undefined || current.dead === true) return;
|
||||
if (!this.canCapture(sessionId)) return;
|
||||
if (this.captureInFlight.has(sessionId)) return;
|
||||
this.captureInFlight.add(sessionId);
|
||||
// Stable within this service, isolated from captures owned by other instances.
|
||||
@@ -410,8 +440,8 @@ export class ObservationService {
|
||||
let writtenPath = outPath;
|
||||
try {
|
||||
const result = await this.deps.queue.run(sessionId, () => {
|
||||
// A foreground lease may have arrived while this capture was queued.
|
||||
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined);
|
||||
// The last viewer may have left, or foreground work arrived, while queued.
|
||||
if (!this.canCapture(sessionId)) return Promise.resolve(undefined);
|
||||
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
|
||||
timeoutMs: 15_000,
|
||||
tag: `observation:${sessionId}`,
|
||||
@@ -445,6 +475,7 @@ export class ObservationService {
|
||||
this.setAvailable(true);
|
||||
this.publishFrame(sessionId, entry, data);
|
||||
} catch {
|
||||
if (this.disposed || !this.observations.has(sessionId)) return;
|
||||
// Foreground preemption is healthy scheduling, not browser/daemon
|
||||
// unavailability; do not let normal tool traffic trip capture backoff.
|
||||
if (!this.capturePreempted.delete(sessionId)) {
|
||||
|
||||
Reference in New Issue
Block a user