src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-21 11:06:55 -0500
committerLuke Hoersten <[email protected]>2026-06-21 11:06:55 -0500
commit000ab3aded7003695af49a9a11a56b8655290133 (patch)
tree2080bda76bc5f8ff06b9b9c7f0fabf20c7902805
parent6c30928dcac2b028ae0323b98c40c0bb0600ff1b (diff)
scrypted: implement StartStop for proper lifecycle UI + clean tear-down
The Scripts 'Status and Controls' panel previously rendered Unknown because the Provider exposed no lifecycle interface. STOP / START in that panel didn't do anything useful — Scrypted's only option was to unload the script entirely, which left the user's re-paste flow relying on the constructor-time globalThis cleanup hack (and even that didn't cover in-flight streams until 1ce49d3). This commit makes the panel real: class ScryptedViewportProvider ... implements ..., StartStop - async start() : drain shutdown cleaners, bootstrap, running=true - async stop() : drain shutdown cleaners, clear viewport+listener+ stream maps, running=false Both methods are idempotent (no-op when already in target state). The constructor calls start() automatically so script load still bootstraps without user action. The private start() method that did the actual provisioning is renamed to bootstrap() to free up the public name for the StartStop contract. registerShutdownCleaners is renamed to drainShutdownCleaners and now takes a 'reason' label so the log line distinguishes 'start: tearing down N resources' (previous load) from 'stop: tearing down N resources' (user-driven). Effect on the workflow the user described: 1. Click STOP in the Scripts UI → stop() drains every resource (streams, listeners, intervals) the provider currently holds. 2. Re-paste the new script. 3. New constructor runs → start() drains anything left behind → bootstrap re-discovers children + re-attaches listeners → ready. No more accumulating ghosts of prior loads.
-rw-r--r--scrypted/scrypted-viewport.ts55
1 files changed, 50 insertions, 5 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index cfd4dbb..e1e99bf 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -82,6 +82,7 @@ type HttpResponse = any;
type Setting = any;
type Settings = any;
type SettingValue = any;
+type StartStop = any;
// ---------------------------------------------------------------------------
// Cross-reload shutdown cleaners.
@@ -96,7 +97,12 @@ type SettingValue = any;
// One unified array keeps the cleanup semantics simple: register the closure
// that knows how to release the resource (clearInterval / abort.abort() /
// reg.removeListener()), and we walk and call them on the next reload.
-function registerShutdownCleaners(console: any) {
+// Drain and run every registered cleanup, leaving the array empty so the
+// next phase can start populating it again. Called from two paths:
+// - script load: tears down resources left over from a previous Provider
+// instance the Scrypted sandbox didn't release
+// - StartStop.stop(): explicit user-driven cleanup via the Scripts UI
+function drainShutdownCleaners(console: any, reason: string) {
const G = globalThis as any;
if (Array.isArray(G.__viewportShutdownCleaners) && G.__viewportShutdownCleaners.length > 0) {
// Snapshot + reset BEFORE walking — some cleanups (stream
@@ -104,7 +110,7 @@ function registerShutdownCleaners(console: any) {
// the array, which would skip elements if we iterated directly.
const prior = G.__viewportShutdownCleaners.slice();
G.__viewportShutdownCleaners = [];
- console.log(`tearing down ${prior.length} resources from previous script load`);
+ console.log(`${reason}: tearing down ${prior.length} resources`);
for (const cleanup of prior) {
try { cleanup(); } catch (e) {
try { console.warn("shutdown cleanup failed:", (e as Error).message); } catch {}
@@ -366,7 +372,7 @@ class Viewport extends ScryptedDeviceBase implements Settings {
// ============================================================================
class ScryptedViewportProvider extends ScryptedDeviceBase
- implements DeviceProvider, DeviceCreator, HttpRequestHandler, Settings {
+ implements DeviceProvider, DeviceCreator, HttpRequestHandler, Settings, StartStop {
private viewports = new Map<string, Viewport>(); // nativeId -> child instance
private listeners = new Map<string, EventListenerRegister[]>(); // nativeId -> all event listeners for this viewport (camera + child devices)
@@ -379,10 +385,49 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
constructor(nativeId?: string) {
super(nativeId);
+ // Auto-start on script load. StartStop.start() is idempotent — if the
+ // user later clicks STOP in the Scripts UI, .stop() drains everything
+ // and start() doesn't re-fire until they click START. Across a
+ // script re-paste the constructor runs again, .start() drains any
+ // prior load's resources first and re-bootstraps cleanly.
this.start().catch(e => this.console.error("start failed", e));
}
// ------------------------------------------------------------------------
+ // StartStop — public lifecycle controls exposed to the Scrypted UI
+ // ------------------------------------------------------------------------
+ //
+ // Scrypted renders this device's "Status and Controls" panel from the
+ // StartStop interface: `running` drives the displayed status and the
+ // STOP / START buttons call .stop() / .start() respectively.
+ //
+ // Semantics:
+ // start(): drain any leftover resources, then bootstrap. Idempotent
+ // (no-op if already running). Auto-called from the
+ // constructor on script load.
+ // stop(): drain every resource (streams, listeners, intervals) the
+ // provider holds. No-op if already stopped.
+ //
+ // The "Unknown" status seen before this commit was Scrypted's fallback
+ // for a device that exposes no lifecycle interface. With StartStop in
+ // place the UI shows Running / Stopped accurately.
+
+ async start() {
+ if (this.running) return;
+ await this.bootstrap();
+ this.running = true;
+ }
+
+ async stop() {
+ if (!this.running) return;
+ drainShutdownCleaners(this.console, "stop");
+ this.viewports.clear();
+ this.listeners.clear();
+ this.streams.clear();
+ this.running = false;
+ }
+
+ // ------------------------------------------------------------------------
// Lifecycle
// ------------------------------------------------------------------------
@@ -395,7 +440,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}
- private async start() {
+ private async bootstrap() {
// endpointManager.getInsecurePublicLocalEndpoint() takes a nativeId
// (string) — passing this.id (numeric Scrypted DB ID) throws
// "invalid nativeId N". this.nativeId is the right key, and an
@@ -451,7 +496,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// down via the closure that knows how (clearInterval / abort /
// removeListener). The new load then starts with an empty
// resource set.
- registerShutdownCleaners(this.console);
+ drainShutdownCleaners(this.console, "start");
const reregisterHandle = setInterval(() => {
for (const v of this.viewports.values()) {
this.registerViewport(v).catch(() => {});