@camstack/system 1.2.196 → 1.2.198
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtins/device-manager/device-manager.addon.js +130 -14
- package/dist/builtins/device-manager/device-manager.addon.mjs +130 -14
- package/dist/builtins/device-manager/device-online-fallback.d.ts +35 -0
- package/dist/builtins/device-manager/device-provider-context.d.ts +6 -0
- package/dist/builtins/device-manager/removal-fence.d.ts +58 -0
- package/package.json +1 -1
|
@@ -1367,6 +1367,17 @@ function resolveDeviceById(registry, deviceId) {
|
|
|
1367
1367
|
};
|
|
1368
1368
|
}
|
|
1369
1369
|
//#endregion
|
|
1370
|
+
//#region src/builtins/device-manager/device-online-fallback.ts
|
|
1371
|
+
/**
|
|
1372
|
+
* The value to hand `resolveDeviceOnline` as its fallback. The mirrored
|
|
1373
|
+
* `device-status` slice, when one exists, still wins over this.
|
|
1374
|
+
*/
|
|
1375
|
+
function persistedOnlineFallback(input) {
|
|
1376
|
+
if (!input.registryPresent) return false;
|
|
1377
|
+
if (input.parentDeviceId !== null) return true;
|
|
1378
|
+
return Object.keys(input.config ?? {}).length > 0;
|
|
1379
|
+
}
|
|
1380
|
+
//#endregion
|
|
1370
1381
|
//#region src/builtins/device-manager/device-queries.ts
|
|
1371
1382
|
/**
|
|
1372
1383
|
* The ORIGINATING addon id off a cap-channel call's `args`, or `undefined`.
|
|
@@ -1384,15 +1395,35 @@ function extractCallerAddonId(input) {
|
|
|
1384
1395
|
}
|
|
1385
1396
|
async function listPersistedByAddon(pctx, input) {
|
|
1386
1397
|
const { addonId } = input;
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1398
|
+
const rows = await pctx.metaStore.rows.listByAddon(addonId);
|
|
1399
|
+
const saved = [];
|
|
1400
|
+
for (const row of rows) {
|
|
1401
|
+
const m = row.meta;
|
|
1402
|
+
if (!row.registered) {
|
|
1403
|
+
const reserved = await pctx.settings.readDeviceStore(m.id);
|
|
1404
|
+
if (Object.keys(reserved ?? {}).length === 0) {
|
|
1405
|
+
pctx.host.ctx.logger.warn("Skipping an unregistered device reservation with no configuration — not a device, not restored", {
|
|
1406
|
+
tags: { deviceId: m.id },
|
|
1407
|
+
meta: {
|
|
1408
|
+
addonId,
|
|
1409
|
+
stableId: m.stableId,
|
|
1410
|
+
type: m.type
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
saved.push({
|
|
1417
|
+
id: m.id,
|
|
1418
|
+
stableId: m.stableId,
|
|
1419
|
+
type: m.type,
|
|
1420
|
+
name: m.name,
|
|
1421
|
+
location: m.location ?? null,
|
|
1422
|
+
disabled: m.disabled ?? false,
|
|
1423
|
+
parentDeviceId: m.parentDeviceId
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
return saved;
|
|
1396
1427
|
}
|
|
1397
1428
|
/**
|
|
1398
1429
|
* Every requested device's persisted config, in as few store round trips as the
|
|
@@ -1476,7 +1507,11 @@ async function listAll(pctx, input) {
|
|
|
1476
1507
|
disabled: m.disabled ?? false,
|
|
1477
1508
|
parentDeviceId: m.parentDeviceId ?? null,
|
|
1478
1509
|
role: toDeviceRole(m.role),
|
|
1479
|
-
online: pctx.host.resolveDeviceOnline(m.id,
|
|
1510
|
+
online: pctx.host.resolveDeviceOnline(m.id, persistedOnlineFallback({
|
|
1511
|
+
registryPresent: pctx.registry !== null,
|
|
1512
|
+
parentDeviceId: m.parentDeviceId ?? null,
|
|
1513
|
+
config: persistedConfig
|
|
1514
|
+
})),
|
|
1480
1515
|
probed: pctx.host.resolveDeviceProbed(m.id),
|
|
1481
1516
|
features: persistedFeatures(m.features),
|
|
1482
1517
|
isCamera: persistedIsCamera(persistedType),
|
|
@@ -1521,7 +1556,11 @@ async function getDevice(pctx, input) {
|
|
|
1521
1556
|
disabled: m.disabled ?? false,
|
|
1522
1557
|
parentDeviceId: m.parentDeviceId,
|
|
1523
1558
|
role: toDeviceRole(m.role),
|
|
1524
|
-
online: pctx.host.resolveDeviceOnline(deviceId,
|
|
1559
|
+
online: pctx.host.resolveDeviceOnline(deviceId, persistedOnlineFallback({
|
|
1560
|
+
registryPresent: true,
|
|
1561
|
+
parentDeviceId: m.parentDeviceId ?? null,
|
|
1562
|
+
config: persistedConfig
|
|
1563
|
+
})),
|
|
1525
1564
|
probed: pctx.host.resolveDeviceProbed(deviceId),
|
|
1526
1565
|
features: persistedFeatures(m.features),
|
|
1527
1566
|
isCamera: persistedIsCamera(m.type),
|
|
@@ -1905,7 +1944,7 @@ async function discoverDevices(pctx, input) {
|
|
|
1905
1944
|
async function adoptDevice(pctx, input) {
|
|
1906
1945
|
const dp = await pctx.host.requireDeviceProvider(input.addonId);
|
|
1907
1946
|
if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
|
|
1908
|
-
const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
|
|
1947
|
+
const summary = await pctx.removalFence.runUnfenced(() => dp.adoptDiscoveredDevice({ candidate: input.candidate }));
|
|
1909
1948
|
if (input.integrationId !== void 0) try {
|
|
1910
1949
|
await pctx.stampIntegrationId(summary.id, input.integrationId);
|
|
1911
1950
|
} catch (err) {
|
|
@@ -1927,10 +1966,10 @@ async function getCreationSchema(pctx, input) {
|
|
|
1927
1966
|
async function createDevice(pctx, input) {
|
|
1928
1967
|
const dp = await pctx.host.requireDeviceProvider(input.addonId);
|
|
1929
1968
|
if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
|
|
1930
|
-
const summary = await dp.createDevice({
|
|
1969
|
+
const summary = await pctx.removalFence.runUnfenced(() => dp.createDevice({
|
|
1931
1970
|
type: input.type,
|
|
1932
1971
|
config: input.config
|
|
1933
|
-
});
|
|
1972
|
+
}));
|
|
1934
1973
|
if (input.integrationId !== void 0) try {
|
|
1935
1974
|
await pctx.stampIntegrationId(summary.id, input.integrationId);
|
|
1936
1975
|
} catch (err) {
|
|
@@ -3792,6 +3831,17 @@ async function allocateDeviceId(pctx, input) {
|
|
|
3792
3831
|
return await pctx.metaStore.withMetaWriteLock(async () => {
|
|
3793
3832
|
const existing = await pctx.metaStore.rows.findByStableId(addonId, stableId);
|
|
3794
3833
|
if (existing) return { id: existing.meta.id };
|
|
3834
|
+
const fenced = pctx.removalFence.isPairFenced(addonId, stableId);
|
|
3835
|
+
if (fenced) {
|
|
3836
|
+
pctx.host.ctx.logger.warn("allocateDeviceId refused — this device was just removed, NOT re-created", {
|
|
3837
|
+
tags: { deviceId: fenced.deviceId },
|
|
3838
|
+
meta: {
|
|
3839
|
+
addonId,
|
|
3840
|
+
stableId
|
|
3841
|
+
}
|
|
3842
|
+
});
|
|
3843
|
+
throw new Error(`[device-manager] allocateDeviceId: device ${fenced.deviceId} (${addonId}/${stableId}) was removed — refusing to re-create it`);
|
|
3844
|
+
}
|
|
3795
3845
|
const id = await pctx.metaStore.allocateNextDeviceId();
|
|
3796
3846
|
await pctx.metaStore.rows.upsert({
|
|
3797
3847
|
addonId,
|
|
@@ -3939,6 +3989,16 @@ async function migrateDevice(pctx, input) {
|
|
|
3939
3989
|
async function registerDevice(pctx, input) {
|
|
3940
3990
|
const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
|
|
3941
3991
|
const featuresArr = Array.isArray(features) ? [...features] : [];
|
|
3992
|
+
if (pctx.removalFence.isIdFenced(id) ?? pctx.removalFence.isPairFenced(addonId, stableId)) {
|
|
3993
|
+
pctx.host.ctx.logger.warn("registerDevice refused — this device was removed, NOT resurrected", {
|
|
3994
|
+
tags: { deviceId: id },
|
|
3995
|
+
meta: {
|
|
3996
|
+
addonId,
|
|
3997
|
+
stableId
|
|
3998
|
+
}
|
|
3999
|
+
});
|
|
4000
|
+
throw new Error(`[device-manager] registerDevice: device ${id} (${addonId}/${stableId}) was removed — refusing to resurrect it`);
|
|
4001
|
+
}
|
|
3942
4002
|
const { isFirstRegistration, exportFingerprint, fingerprintChanged, inheritedLocation, inheritedDescendantIds, inheritFailed } = await pctx.metaStore.withMetaWriteLock(async () => {
|
|
3943
4003
|
const existingRow = await pctx.metaStore.rows.get(id);
|
|
3944
4004
|
const existingMeta = existingRow?.meta;
|
|
@@ -4079,6 +4139,11 @@ async function removeDevice(pctx, input) {
|
|
|
4079
4139
|
const deviceName = persistedMeta.name;
|
|
4080
4140
|
await pctx.metaStore.withMetaWriteLock(async () => {
|
|
4081
4141
|
await pctx.metaStore.rows.remove(deviceId);
|
|
4142
|
+
pctx.removalFence.record({
|
|
4143
|
+
deviceId,
|
|
4144
|
+
addonId,
|
|
4145
|
+
stableId
|
|
4146
|
+
});
|
|
4082
4147
|
});
|
|
4083
4148
|
await pctx.settings.clearDeviceStore(deviceId);
|
|
4084
4149
|
await pctx.settings.clearDeviceRuntimeState(deviceId);
|
|
@@ -6444,6 +6509,56 @@ function createMigrationGuard(now = Date.now) {
|
|
|
6444
6509
|
}
|
|
6445
6510
|
};
|
|
6446
6511
|
}
|
|
6512
|
+
function pairKey(addonId, stableId) {
|
|
6513
|
+
return `${addonId}${stableId}`;
|
|
6514
|
+
}
|
|
6515
|
+
function createRemovalFence(options = {}) {
|
|
6516
|
+
const ttlMs = options.ttlMs ?? 3e5;
|
|
6517
|
+
const now = options.now ?? (() => Date.now());
|
|
6518
|
+
const byId = /* @__PURE__ */ new Map();
|
|
6519
|
+
const byPair = /* @__PURE__ */ new Map();
|
|
6520
|
+
let suspensions = 0;
|
|
6521
|
+
const fresh = (entry) => {
|
|
6522
|
+
if (entry === void 0) return null;
|
|
6523
|
+
if (suspensions > 0) return null;
|
|
6524
|
+
if (now() - entry.removedAt >= ttlMs) {
|
|
6525
|
+
byId.delete(entry.deviceId);
|
|
6526
|
+
byPair.delete(pairKey(entry.addonId, entry.stableId));
|
|
6527
|
+
return null;
|
|
6528
|
+
}
|
|
6529
|
+
return entry;
|
|
6530
|
+
};
|
|
6531
|
+
return {
|
|
6532
|
+
record(entry) {
|
|
6533
|
+
const full = {
|
|
6534
|
+
...entry,
|
|
6535
|
+
removedAt: now()
|
|
6536
|
+
};
|
|
6537
|
+
byId.set(full.deviceId, full);
|
|
6538
|
+
byPair.set(pairKey(full.addonId, full.stableId), full);
|
|
6539
|
+
},
|
|
6540
|
+
isIdFenced(deviceId) {
|
|
6541
|
+
return fresh(byId.get(deviceId));
|
|
6542
|
+
},
|
|
6543
|
+
isPairFenced(addonId, stableId) {
|
|
6544
|
+
return fresh(byPair.get(pairKey(addonId, stableId)));
|
|
6545
|
+
},
|
|
6546
|
+
lift(addonId, stableId) {
|
|
6547
|
+
const entry = byPair.get(pairKey(addonId, stableId));
|
|
6548
|
+
if (entry === void 0) return;
|
|
6549
|
+
byPair.delete(pairKey(addonId, stableId));
|
|
6550
|
+
byId.delete(entry.deviceId);
|
|
6551
|
+
},
|
|
6552
|
+
async runUnfenced(fn) {
|
|
6553
|
+
suspensions += 1;
|
|
6554
|
+
try {
|
|
6555
|
+
return await fn();
|
|
6556
|
+
} finally {
|
|
6557
|
+
suspensions -= 1;
|
|
6558
|
+
}
|
|
6559
|
+
}
|
|
6560
|
+
};
|
|
6561
|
+
}
|
|
6447
6562
|
//#endregion
|
|
6448
6563
|
//#region src/builtins/device-manager/device-manager.addon.ts
|
|
6449
6564
|
var DeviceManagerAddon = class extends require_dist.BaseAddon {
|
|
@@ -6686,6 +6801,7 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
|
|
|
6686
6801
|
registry,
|
|
6687
6802
|
bindingsDeps: this.bindingsDeps,
|
|
6688
6803
|
migrationGuard: createMigrationGuard(),
|
|
6804
|
+
removalFence: createRemovalFence(),
|
|
6689
6805
|
requireDeviceOps,
|
|
6690
6806
|
stampIntegrationId: stampIntegrationId$1,
|
|
6691
6807
|
get provider() {
|
|
@@ -1362,6 +1362,17 @@ function resolveDeviceById(registry, deviceId) {
|
|
|
1362
1362
|
};
|
|
1363
1363
|
}
|
|
1364
1364
|
//#endregion
|
|
1365
|
+
//#region src/builtins/device-manager/device-online-fallback.ts
|
|
1366
|
+
/**
|
|
1367
|
+
* The value to hand `resolveDeviceOnline` as its fallback. The mirrored
|
|
1368
|
+
* `device-status` slice, when one exists, still wins over this.
|
|
1369
|
+
*/
|
|
1370
|
+
function persistedOnlineFallback(input) {
|
|
1371
|
+
if (!input.registryPresent) return false;
|
|
1372
|
+
if (input.parentDeviceId !== null) return true;
|
|
1373
|
+
return Object.keys(input.config ?? {}).length > 0;
|
|
1374
|
+
}
|
|
1375
|
+
//#endregion
|
|
1365
1376
|
//#region src/builtins/device-manager/device-queries.ts
|
|
1366
1377
|
/**
|
|
1367
1378
|
* The ORIGINATING addon id off a cap-channel call's `args`, or `undefined`.
|
|
@@ -1379,15 +1390,35 @@ function extractCallerAddonId(input) {
|
|
|
1379
1390
|
}
|
|
1380
1391
|
async function listPersistedByAddon(pctx, input) {
|
|
1381
1392
|
const { addonId } = input;
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1393
|
+
const rows = await pctx.metaStore.rows.listByAddon(addonId);
|
|
1394
|
+
const saved = [];
|
|
1395
|
+
for (const row of rows) {
|
|
1396
|
+
const m = row.meta;
|
|
1397
|
+
if (!row.registered) {
|
|
1398
|
+
const reserved = await pctx.settings.readDeviceStore(m.id);
|
|
1399
|
+
if (Object.keys(reserved ?? {}).length === 0) {
|
|
1400
|
+
pctx.host.ctx.logger.warn("Skipping an unregistered device reservation with no configuration — not a device, not restored", {
|
|
1401
|
+
tags: { deviceId: m.id },
|
|
1402
|
+
meta: {
|
|
1403
|
+
addonId,
|
|
1404
|
+
stableId: m.stableId,
|
|
1405
|
+
type: m.type
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
saved.push({
|
|
1412
|
+
id: m.id,
|
|
1413
|
+
stableId: m.stableId,
|
|
1414
|
+
type: m.type,
|
|
1415
|
+
name: m.name,
|
|
1416
|
+
location: m.location ?? null,
|
|
1417
|
+
disabled: m.disabled ?? false,
|
|
1418
|
+
parentDeviceId: m.parentDeviceId
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
return saved;
|
|
1391
1422
|
}
|
|
1392
1423
|
/**
|
|
1393
1424
|
* Every requested device's persisted config, in as few store round trips as the
|
|
@@ -1471,7 +1502,11 @@ async function listAll(pctx, input) {
|
|
|
1471
1502
|
disabled: m.disabled ?? false,
|
|
1472
1503
|
parentDeviceId: m.parentDeviceId ?? null,
|
|
1473
1504
|
role: toDeviceRole(m.role),
|
|
1474
|
-
online: pctx.host.resolveDeviceOnline(m.id,
|
|
1505
|
+
online: pctx.host.resolveDeviceOnline(m.id, persistedOnlineFallback({
|
|
1506
|
+
registryPresent: pctx.registry !== null,
|
|
1507
|
+
parentDeviceId: m.parentDeviceId ?? null,
|
|
1508
|
+
config: persistedConfig
|
|
1509
|
+
})),
|
|
1475
1510
|
probed: pctx.host.resolveDeviceProbed(m.id),
|
|
1476
1511
|
features: persistedFeatures(m.features),
|
|
1477
1512
|
isCamera: persistedIsCamera(persistedType),
|
|
@@ -1516,7 +1551,11 @@ async function getDevice(pctx, input) {
|
|
|
1516
1551
|
disabled: m.disabled ?? false,
|
|
1517
1552
|
parentDeviceId: m.parentDeviceId,
|
|
1518
1553
|
role: toDeviceRole(m.role),
|
|
1519
|
-
online: pctx.host.resolveDeviceOnline(deviceId,
|
|
1554
|
+
online: pctx.host.resolveDeviceOnline(deviceId, persistedOnlineFallback({
|
|
1555
|
+
registryPresent: true,
|
|
1556
|
+
parentDeviceId: m.parentDeviceId ?? null,
|
|
1557
|
+
config: persistedConfig
|
|
1558
|
+
})),
|
|
1520
1559
|
probed: pctx.host.resolveDeviceProbed(deviceId),
|
|
1521
1560
|
features: persistedFeatures(m.features),
|
|
1522
1561
|
isCamera: persistedIsCamera(m.type),
|
|
@@ -1900,7 +1939,7 @@ async function discoverDevices(pctx, input) {
|
|
|
1900
1939
|
async function adoptDevice(pctx, input) {
|
|
1901
1940
|
const dp = await pctx.host.requireDeviceProvider(input.addonId);
|
|
1902
1941
|
if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
|
|
1903
|
-
const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
|
|
1942
|
+
const summary = await pctx.removalFence.runUnfenced(() => dp.adoptDiscoveredDevice({ candidate: input.candidate }));
|
|
1904
1943
|
if (input.integrationId !== void 0) try {
|
|
1905
1944
|
await pctx.stampIntegrationId(summary.id, input.integrationId);
|
|
1906
1945
|
} catch (err) {
|
|
@@ -1922,10 +1961,10 @@ async function getCreationSchema(pctx, input) {
|
|
|
1922
1961
|
async function createDevice(pctx, input) {
|
|
1923
1962
|
const dp = await pctx.host.requireDeviceProvider(input.addonId);
|
|
1924
1963
|
if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
|
|
1925
|
-
const summary = await dp.createDevice({
|
|
1964
|
+
const summary = await pctx.removalFence.runUnfenced(() => dp.createDevice({
|
|
1926
1965
|
type: input.type,
|
|
1927
1966
|
config: input.config
|
|
1928
|
-
});
|
|
1967
|
+
}));
|
|
1929
1968
|
if (input.integrationId !== void 0) try {
|
|
1930
1969
|
await pctx.stampIntegrationId(summary.id, input.integrationId);
|
|
1931
1970
|
} catch (err) {
|
|
@@ -3787,6 +3826,17 @@ async function allocateDeviceId(pctx, input) {
|
|
|
3787
3826
|
return await pctx.metaStore.withMetaWriteLock(async () => {
|
|
3788
3827
|
const existing = await pctx.metaStore.rows.findByStableId(addonId, stableId);
|
|
3789
3828
|
if (existing) return { id: existing.meta.id };
|
|
3829
|
+
const fenced = pctx.removalFence.isPairFenced(addonId, stableId);
|
|
3830
|
+
if (fenced) {
|
|
3831
|
+
pctx.host.ctx.logger.warn("allocateDeviceId refused — this device was just removed, NOT re-created", {
|
|
3832
|
+
tags: { deviceId: fenced.deviceId },
|
|
3833
|
+
meta: {
|
|
3834
|
+
addonId,
|
|
3835
|
+
stableId
|
|
3836
|
+
}
|
|
3837
|
+
});
|
|
3838
|
+
throw new Error(`[device-manager] allocateDeviceId: device ${fenced.deviceId} (${addonId}/${stableId}) was removed — refusing to re-create it`);
|
|
3839
|
+
}
|
|
3790
3840
|
const id = await pctx.metaStore.allocateNextDeviceId();
|
|
3791
3841
|
await pctx.metaStore.rows.upsert({
|
|
3792
3842
|
addonId,
|
|
@@ -3934,6 +3984,16 @@ async function migrateDevice(pctx, input) {
|
|
|
3934
3984
|
async function registerDevice(pctx, input) {
|
|
3935
3985
|
const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
|
|
3936
3986
|
const featuresArr = Array.isArray(features) ? [...features] : [];
|
|
3987
|
+
if (pctx.removalFence.isIdFenced(id) ?? pctx.removalFence.isPairFenced(addonId, stableId)) {
|
|
3988
|
+
pctx.host.ctx.logger.warn("registerDevice refused — this device was removed, NOT resurrected", {
|
|
3989
|
+
tags: { deviceId: id },
|
|
3990
|
+
meta: {
|
|
3991
|
+
addonId,
|
|
3992
|
+
stableId
|
|
3993
|
+
}
|
|
3994
|
+
});
|
|
3995
|
+
throw new Error(`[device-manager] registerDevice: device ${id} (${addonId}/${stableId}) was removed — refusing to resurrect it`);
|
|
3996
|
+
}
|
|
3937
3997
|
const { isFirstRegistration, exportFingerprint, fingerprintChanged, inheritedLocation, inheritedDescendantIds, inheritFailed } = await pctx.metaStore.withMetaWriteLock(async () => {
|
|
3938
3998
|
const existingRow = await pctx.metaStore.rows.get(id);
|
|
3939
3999
|
const existingMeta = existingRow?.meta;
|
|
@@ -4074,6 +4134,11 @@ async function removeDevice(pctx, input) {
|
|
|
4074
4134
|
const deviceName = persistedMeta.name;
|
|
4075
4135
|
await pctx.metaStore.withMetaWriteLock(async () => {
|
|
4076
4136
|
await pctx.metaStore.rows.remove(deviceId);
|
|
4137
|
+
pctx.removalFence.record({
|
|
4138
|
+
deviceId,
|
|
4139
|
+
addonId,
|
|
4140
|
+
stableId
|
|
4141
|
+
});
|
|
4077
4142
|
});
|
|
4078
4143
|
await pctx.settings.clearDeviceStore(deviceId);
|
|
4079
4144
|
await pctx.settings.clearDeviceRuntimeState(deviceId);
|
|
@@ -6439,6 +6504,56 @@ function createMigrationGuard(now = Date.now) {
|
|
|
6439
6504
|
}
|
|
6440
6505
|
};
|
|
6441
6506
|
}
|
|
6507
|
+
function pairKey(addonId, stableId) {
|
|
6508
|
+
return `${addonId}${stableId}`;
|
|
6509
|
+
}
|
|
6510
|
+
function createRemovalFence(options = {}) {
|
|
6511
|
+
const ttlMs = options.ttlMs ?? 3e5;
|
|
6512
|
+
const now = options.now ?? (() => Date.now());
|
|
6513
|
+
const byId = /* @__PURE__ */ new Map();
|
|
6514
|
+
const byPair = /* @__PURE__ */ new Map();
|
|
6515
|
+
let suspensions = 0;
|
|
6516
|
+
const fresh = (entry) => {
|
|
6517
|
+
if (entry === void 0) return null;
|
|
6518
|
+
if (suspensions > 0) return null;
|
|
6519
|
+
if (now() - entry.removedAt >= ttlMs) {
|
|
6520
|
+
byId.delete(entry.deviceId);
|
|
6521
|
+
byPair.delete(pairKey(entry.addonId, entry.stableId));
|
|
6522
|
+
return null;
|
|
6523
|
+
}
|
|
6524
|
+
return entry;
|
|
6525
|
+
};
|
|
6526
|
+
return {
|
|
6527
|
+
record(entry) {
|
|
6528
|
+
const full = {
|
|
6529
|
+
...entry,
|
|
6530
|
+
removedAt: now()
|
|
6531
|
+
};
|
|
6532
|
+
byId.set(full.deviceId, full);
|
|
6533
|
+
byPair.set(pairKey(full.addonId, full.stableId), full);
|
|
6534
|
+
},
|
|
6535
|
+
isIdFenced(deviceId) {
|
|
6536
|
+
return fresh(byId.get(deviceId));
|
|
6537
|
+
},
|
|
6538
|
+
isPairFenced(addonId, stableId) {
|
|
6539
|
+
return fresh(byPair.get(pairKey(addonId, stableId)));
|
|
6540
|
+
},
|
|
6541
|
+
lift(addonId, stableId) {
|
|
6542
|
+
const entry = byPair.get(pairKey(addonId, stableId));
|
|
6543
|
+
if (entry === void 0) return;
|
|
6544
|
+
byPair.delete(pairKey(addonId, stableId));
|
|
6545
|
+
byId.delete(entry.deviceId);
|
|
6546
|
+
},
|
|
6547
|
+
async runUnfenced(fn) {
|
|
6548
|
+
suspensions += 1;
|
|
6549
|
+
try {
|
|
6550
|
+
return await fn();
|
|
6551
|
+
} finally {
|
|
6552
|
+
suspensions -= 1;
|
|
6553
|
+
}
|
|
6554
|
+
}
|
|
6555
|
+
};
|
|
6556
|
+
}
|
|
6442
6557
|
//#endregion
|
|
6443
6558
|
//#region src/builtins/device-manager/device-manager.addon.ts
|
|
6444
6559
|
var DeviceManagerAddon = class extends BaseAddon {
|
|
@@ -6681,6 +6796,7 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
6681
6796
|
registry,
|
|
6682
6797
|
bindingsDeps: this.bindingsDeps,
|
|
6683
6798
|
migrationGuard: createMigrationGuard(),
|
|
6799
|
+
removalFence: createRemovalFence(),
|
|
6684
6800
|
requireDeviceOps,
|
|
6685
6801
|
stampIntegrationId: stampIntegrationId$1,
|
|
6686
6802
|
get provider() {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What "online" means for a device the hub can only see on disk.
|
|
3
|
+
*
|
|
4
|
+
* The authority for a device's liveness is the addon that owns it (D224): it
|
|
5
|
+
* publishes `device-status` through `markOnline`, the hub mirrors that slice,
|
|
6
|
+
* and every persisted-row projection reads the mirror. The FALLBACK — what the
|
|
7
|
+
* hub answers before any slice has arrived — used to be "a persisted row
|
|
8
|
+
* implies a successful registration, so: online". That assumption is false for
|
|
9
|
+
* exactly one shape of row, and it is the shape a bad removal leaves behind: a
|
|
10
|
+
* top-level device with NO configuration, which its provider can never restore
|
|
11
|
+
* (its config schema rejects an empty blob), so no owner will ever publish a
|
|
12
|
+
* slice contradicting the guess.
|
|
13
|
+
*
|
|
14
|
+
* Device 614 was reported `online: true` for a full day that way, while its
|
|
15
|
+
* provider logged `Failed to restore device` eight times. A device without
|
|
16
|
+
* configuration cannot be online — the row is a leftover, not a camera. See
|
|
17
|
+
* D376.
|
|
18
|
+
*
|
|
19
|
+
* Children are left on the legacy fallback deliberately: an accessory child
|
|
20
|
+
* (siren, floodlight, PIR) legitimately carries an empty config blob and gets
|
|
21
|
+
* its liveness from the parent's worker.
|
|
22
|
+
*/
|
|
23
|
+
export interface PersistedOnlineFallbackInput {
|
|
24
|
+
/** Is a hub-side device registry present at all? `false` is the "offline view". */
|
|
25
|
+
readonly registryPresent: boolean;
|
|
26
|
+
/** `null` for a top-level device. */
|
|
27
|
+
readonly parentDeviceId: number | null;
|
|
28
|
+
/** The device's persisted config blob, as read from its device store. */
|
|
29
|
+
readonly config: Record<string, unknown> | undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The value to hand `resolveDeviceOnline` as its fallback. The mirrored
|
|
33
|
+
* `device-status` slice, when one exists, still wins over this.
|
|
34
|
+
*/
|
|
35
|
+
export declare function persistedOnlineFallback(input: PersistedOnlineFallbackInput): boolean;
|
|
@@ -3,6 +3,7 @@ import { AdoptionJobEngine } from './adoption-job-engine.js';
|
|
|
3
3
|
import { BindingsDeps } from './device-bindings-store.js';
|
|
4
4
|
import { DeviceManagerSettings, DeviceMetaStore } from './device-meta-store.js';
|
|
5
5
|
import { MigrationGuard } from './migrate-guard.js';
|
|
6
|
+
import { RemovalFence } from './removal-fence.js';
|
|
6
7
|
type IDeviceProviderCap = InferProvider<typeof deviceProviderCapability>;
|
|
7
8
|
/**
|
|
8
9
|
* The addon-instance surface the extracted provider functions need. Exposed by
|
|
@@ -51,6 +52,11 @@ export interface ProviderContext {
|
|
|
51
52
|
/** In-flight refusal + completed-swap replay for `migrateDevice` — the
|
|
52
53
|
* reason a retried migration can no longer undo the one that ran. */
|
|
53
54
|
readonly migrationGuard: MigrationGuard;
|
|
55
|
+
/** Keeps a removed device removed: refuses the `registerDevice` /
|
|
56
|
+
* `allocateDeviceId` that a still-live device object issues as its runner
|
|
57
|
+
* tears down, which is how a deleted camera came back as a config-less
|
|
58
|
+
* shell (D376). */
|
|
59
|
+
readonly removalFence: RemovalFence;
|
|
54
60
|
/** Resolve the per-device `device-ops` native provider (throws when absent). */
|
|
55
61
|
requireDeviceOps(deviceId: number): IDeviceOpsProvider;
|
|
56
62
|
/** Stamp an integration id onto a device's meta row + emit DeviceMetaChanged. */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A removed device stays removed.
|
|
3
|
+
*
|
|
4
|
+
* `removeDevice` deletes the row and clears the device's config store, but
|
|
5
|
+
* NOTHING stopped a write that was already on its way from putting the row
|
|
6
|
+
* back: `registerDevice` upserts unconditionally (`upsertRegistration` doubles
|
|
7
|
+
* as the INSERT), and `allocateDeviceId` mints a fresh row for any
|
|
8
|
+
* `(addonId, stableId)` it cannot find. A live device object whose runner had
|
|
9
|
+
* not finished tearing down — a probe retry, a reconcile, an accessory
|
|
10
|
+
* re-register — therefore resurrected the row it had just lost, with its
|
|
11
|
+
* construction-time name and type intact and its config GONE, because the
|
|
12
|
+
* config was cleared by the removal and `registerDevice` does not write config.
|
|
13
|
+
*
|
|
14
|
+
* That is exactly the shell device 614 ("Videocamera studio") was left in on
|
|
15
|
+
* 2026-09-05: row present, `config: {}`, `disabled: false`, `online: true`,
|
|
16
|
+
* failing its provider's Zod schema on `host`/`password` at every boot for a
|
|
17
|
+
* day. See D376.
|
|
18
|
+
*
|
|
19
|
+
* The fence is deliberately IN-MEMORY and short-lived. The race it closes is
|
|
20
|
+
* a teardown that is seconds wide, in the one process that owns the rows; a
|
|
21
|
+
* durable tombstone would be a second authority on "does this device exist"
|
|
22
|
+
* and this repo has been burned by those. A deliberate re-add of the same
|
|
23
|
+
* hardware (`createDevice` / `adoptDevice`) lifts the fence for that pair
|
|
24
|
+
* explicitly, so an operator is never told "no" for their own action.
|
|
25
|
+
*/
|
|
26
|
+
/** How long a removed identity refuses to be re-created. */
|
|
27
|
+
export declare const REMOVAL_FENCE_TTL_MS: number;
|
|
28
|
+
export interface RemovalFenceEntry {
|
|
29
|
+
readonly deviceId: number;
|
|
30
|
+
readonly addonId: string;
|
|
31
|
+
readonly stableId: string;
|
|
32
|
+
readonly removedAt: number;
|
|
33
|
+
}
|
|
34
|
+
export interface RemovalFence {
|
|
35
|
+
/** Record a completed removal. */
|
|
36
|
+
record(entry: Omit<RemovalFenceEntry, 'removedAt'>): void;
|
|
37
|
+
/** Is this numeric id fenced? */
|
|
38
|
+
isIdFenced(deviceId: number): RemovalFenceEntry | null;
|
|
39
|
+
/** Is this addon-facing identity fenced? */
|
|
40
|
+
isPairFenced(addonId: string, stableId: string): RemovalFenceEntry | null;
|
|
41
|
+
/** Lift the fence for one identity — an operator re-adding the device. */
|
|
42
|
+
lift(addonId: string, stableId: string): void;
|
|
43
|
+
/**
|
|
44
|
+
* Run an operator-initiated create/adopt with the fence down.
|
|
45
|
+
*
|
|
46
|
+
* `createDevice` / `adoptDevice` do not know the stableId in advance — the
|
|
47
|
+
* provider derives it from the hardware — so re-adding a camera that was
|
|
48
|
+
* just deleted cannot lift a single pair. It suspends the whole fence for
|
|
49
|
+
* the duration of the operator's own action instead, which is the one case
|
|
50
|
+
* where "this identity was removed" must not win.
|
|
51
|
+
*/
|
|
52
|
+
runUnfenced<T>(fn: () => Promise<T>): Promise<T>;
|
|
53
|
+
}
|
|
54
|
+
export interface RemovalFenceOptions {
|
|
55
|
+
readonly ttlMs?: number;
|
|
56
|
+
readonly now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
export declare function createRemovalFence(options?: RemovalFenceOptions): RemovalFence;
|