land: area/client-reliability f39622da3b (client-reliability)

This commit is contained in:
thePR0M3TH3AN committed 2026-10-20 12:15:12 -04:00
commit b117416355
8 files changed
+331 -2

No files matched your search

@@ -0,0 +1,6 @@
# Item7 cold live feed notification
- Goal: cold cache then eligible live updates must notify actual loadVideos consumer;latest feed owns reused subscription and clear invalidates late callbacks.
- Scope: nostrService loadVideos notification state plus constructor/clear lifecycle only;new focused service test and unique evidence/backlog. No app/feed/filter/cache policy/runner changes.
- Plan: reproduce original service with controlled subscribeVideos boundary;prove latest/force/clear controls;minimal current-consumer token;mutants/focused/lint;explicit full slot then one unchanged normal gate;QA owns actual-app browser after landing/preview rebuild.
- Original4e904349: new first5 scenarios4fail1pass/0cancel;first8line callback fix later5pass2fail on7. Revised service19 added lines passes7/7. No actual browser/full/build yet.
@@ -0,0 +1,6 @@
# Item7 notification ownership
- Live updates notify the same full ordered cached list emitted/persisted already,including deletion-only empty results. Preserve event payload/deleted metadata and filtering.
- A service-owned notification object carries latest loadVideos consumer across reused subscription. Creating replacement subscription uses fresh identity;clear invalidates before unsub. Old subscription callback cannot change cache,notify consumer,emit or persist after disposal.
- Interpreter independently identified stale captured consumer;our A→B and force/clear tests fail firstcallbackpatch,pass revisedtoken. Service lifecycle state explicitly routed by dispatch16:10:24.
- Keep existing cache/persistent callbacks and callback-throw isolation. Event-only consumers remain supported. No productpolicy change/forced UI refresh. Browser qualification belongs to QA.
@@ -1,10 +1,10 @@
{
"id": "client-reliability-7",
"area": "client-reliability",
"status": "open",
"status": "in-progress",
"title": "Restore cold-feed live subscription onVideos notification after eligible relay updates",
"blockedOn": null,
"addedOn": "2026-10-20",
"filedAt": "386cc60e1fa3ba6d9b67a1171a1ab89c03df84ca",
"evidence": "QA actual app at386cc60e:cache0\u2192EVENT3/EOSE\u2192filtered3/access/include true\u2192viewEMPTY/DOM0. live service branch emits videos:updated but omits onVideos callback used by actual feed refresh. Scope/reproduction/controls in area prompt;minimal method-only fix after natural failing regression."
"evidence": "Reproduced actual method original4fail1pass at4e904349,firstcallbackpatch5pass2fail(latest/forceclear),revised7/7;9mutants naturallyexit1/0cancel;focused5files27passing,lint11/11. Service19addedlines loadVideos+constructor/clear only. Ready note test_logs/TEST_LOG_2026-10-20_16-14-00_client-reliability-7-ready.md;await fullslot,no full/browser/build yet."
}
+19
View File
@@ -619,6 +619,7 @@ export class NostrService {
}
});
this.videosMap = null;
this.videoFeedNotification = null;
this.videosByAuthorIndex = null;
this.authorIndexDirty = false;
this.moderationService = moderationService || null;
@@ -1429,6 +1430,7 @@ export class NostrService {
}
clearVideoSubscription() {
this.videoFeedNotification = null;
const current = getStoredVideoSubscription();
if (current && typeof current.unsub === "function") {
try {
@@ -1713,6 +1715,12 @@ export class NostrService {
this.clearVideoSubscription();
}
// Reused relay subscriptions follow the currently displayed feed consumer.
const notification =
(getStoredVideoSubscription() && this.videoFeedNotification) || {};
notification.onVideos = onVideos;
this.videoFeedNotification = notification;
const applyAndNotify = (videos, reason) => {
const filtered = this.filterVideos(videos, {
blacklistedEventIds,
@@ -1793,6 +1801,9 @@ export class NostrService {
};
const subscription = this.nostrClient.subscribeVideos(
(updatedVideos) => {
if (this.videoFeedNotification !== notification) {
return;
}
if (!Array.isArray(updatedVideos) || !updatedVideos.length) {
return;
}
@@ -1837,6 +1848,14 @@ export class NostrService {
return bCreated - aCreated;
});
if (typeof notification.onVideos === "function") {
try {
notification.onVideos(fullList, { reason: "subscription" });
} catch (error) {
userLogger.warn("[nostrService] onVideos handler threw", error);
}
}
this.emit("videos:updated", {
videos: fullList,
deleted: deletedUpdates,
@@ -0,0 +1,55 @@
# Item7 live feed consumer notification
```yaml
test_integrity_note:
change_type: ["new_tests"]
scenarios:
- id: SCN-cold-live-feed
given: "actual service cold cache and controlled eligible/ineligible subscription arrivals"
when: "two live batches arrive"
then: "consumer,events and persisted cache expose same full ordered eligible snapshots"
- id: SCN-latest-feed-consumer
given: "one live relay subscription and successive loadVideos consumers"
when: "consumer switches then becomes event-only"
then: "only latest consumer receives live updates;event-only updates still emit and persist"
- id: SCN-replaced-feed-subscription
given: "live subscription is force-replaced then cleared"
when: "old controlled callbacks arrive after unsubscribe"
then: "no stale callback effects;current subscription notifies and persists normally"
- id: SCN-live-deletion
given: "two cached videos"
when: "deletion-only,mixed deletion+addition,and final deletion arrive"
then: "consumer gets reduced,updated and empty full snapshots;deleted metadata and persistence preserved"
- id: SCN-live-consumer-throw
given: "consumer throws on first live arrival"
when: "two live batches arrive"
then: "service events,persistence and later callback delivery remain intact"
- id: SCN-warm-live-feed
given: "persistent warm cache and empty in-memory input"
when: "load then live arrival"
then: "optimistic data survives without empty flash;live snapshot includes old and new entries"
- id: SCN-event-only-filtered-feed
given: "no onVideos consumer"
when: "empty,ineligible,then eligible updates arrive"
then: "empty/ineligible updates do not notify/cache;eligible update emits and persists without consumer"
observable_outcomes:
- "real loadVideos callback values,service events,shared map and localStorage snapshots"
- "latest callback ownership,unsubscribe lifecycle and no stale effects"
determinism_controls:
- "controlled nostrClient.subscribeVideos boundary;actual service filter/cache/persistence/emitter"
- "fixed videos/order,isolated shared state/storage;global network guards;no sleeps/retries/public traffic"
- "teardown restores globals/map/subscription/storage and verifies owned subscriptions close"
anti_cheat_rationale:
prevents:
- "event-only/source-string assertion mistaken for actual consumer delivery"
- "notify delta or stale first consumer instead of current full-list consumer"
- "lose deletion/event/persistence on callback exceptions or no consumer"
- "filter bypass or optimistic cache flash"
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
Nine isolated runtime loader mutants all naturally exit1/0cancel and restore at process termination: missing-live kills scenarios1-6;delta-only kills1/4/5/6;ignore-deletions kills4;uncaught-consumer kills5;first-consumer kills2;late-callback kills3;event-only-drop kills2/7;retained-on-clear kills3;filter-bypass kills1/7. No source file changed during mutation runs; no arbitrary sleeps/retries/relaxed assertions. Outputs `/tmp/item7-mutant-*.log` and `/tmp/item7-mutants.json`.
Scope is service notification contract with controlled client IO;not real wire parser/signing/relay/bootstrap/DOM/mobile proof. QA measured actual app failure independently and owns postlanding qualification. Existing suites are unchanged.
@@ -0,0 +1,9 @@
# Item7 ready evidence
- Baseline4e90434992471c3c6b40b017c6eae133aec92b62;filed386cc60e source unchanged. `env -u NODE_TEST_CONTEXT NODE_ENV=test node tests/nostr-service-live-feed-notification.test.mjs` beforeproductedit naturalexit1:4fail1pass/0cancel/0skip (`/tmp/item7-before.log`);cached0→eligiblefull3/persisted3 but consumer receives only cache0. Deletion/warm/throw scenarios also fail;ineligible/event-only controls pass.
- First8line closed-over callback addition5/5 passed;then latest-consumer/force/clear controls failnaturally,5pass2fail/0cancel (`/tmp/item7-first-callback-controls.log`). Revised service19added lines (loadVideos+constructor/clear state)7/7 naturalexit0,0fail/cancel/skip (`/tmp/item7-owned-after.log`). No existing tests changed.
- `python3 /tmp/item7-mutants.py`:9/9 isolated source-loader mutants each naturalexit1,0cancelled,named assertions;no disk source mutation. Summary `/tmp/item7-mutants.json`,outputs `/tmp/item7-mutant-*.log`;mapping unique integrity note.
- Five focused files executed sequentially with `env` NODE_TEST_CONTEXT removed,NODE_ENV=test,each `node --import ./tests/test-helpers/setup-localstorage.mjs <file>` and existing60s/file diagnostic bound. New7+access-ready4+access5+service6+channelcache5=27pass,all5processesexit0 (`/tmp/item7-focused.log`). Access5 prints five TAP ok lines but has pre-existing forced-exit afterhook/no final summary;not edited. Otherfiles0fail/cancel/skip.
- `env -u NODE_TEST_CONTEXT npm run lint` naturalexit0,11stages (`/tmp/item7-lint.log`). `git diff --check` exit0.
- No live network,signer/protocol changes,full/build/browser run. Fake client only controls subscription/cache input and existing access boundary;actual service filter/cache/emitter/persistence. Cleanup restores shared app map/subscription/storage/globals and closes owned subscriptions. Service moderation disabled in fixture to avoid unrelated singleton refresh side effects.
- QA owns actual app retest and dispatch preview rebuild. Waiting explicit full normal slot;single unchanged normal gate authoritative full candidate run,no duplicate full solely for logs. Live relay/signing/real peers/core/mobile remain unverified by these service tests.
@@ -0,0 +1,227 @@
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import assert from "node:assert/strict";
import { NostrService } from "../js/services/nostrService.js";
import {
getVideosMap,
setVideosMap,
getVideoSubscription,
setVideoSubscription,
} from "../js/state/appState.js";
const storageKey = "bitvid:filtered-videos:v1";
const video = (id, created_at, extra = {}) => ({
id,
created_at,
pubkey: "a".repeat(64),
title: id,
url: "https://example.invalid/video.mp4",
...extra,
});
const ids = (videos) => videos.map((entry) => entry.id);
function fixture(t, { cached = [], persisted = [] } = {}) {
const previousMap = getVideosMap();
const previousSubscription = getVideoSubscription();
const previousStorage = window.localStorage.getItem(storageKey);
setVideosMap(new Map());
setVideoSubscription(null);
window.localStorage.setItem(storageKey, JSON.stringify(persisted));
const service = new NostrService();
service.moderationService = null;
const events = [];
const subscriptions = [];
const unsubscribe = service.on("videos:updated", (detail) => {
events.push({ ...detail, videos: detail.videos.slice() });
});
let receive;
let networkAttempts = 0;
const globals = ["fetch", "WebSocket"].map((key) => [
key, Object.getOwnPropertyDescriptor(globalThis, key),
]);
globalThis.fetch = () => {
networkAttempts++;
throw new Error("Unexpected live-feed network IO");
};
globalThis.WebSocket = class {
constructor() {
networkAttempts++;
throw new Error("Unexpected live-feed socket");
}
};
t.after(() => {
service.clearVideoSubscription();
unsubscribe();
setVideosMap(previousMap);
setVideoSubscription(previousSubscription);
if (previousStorage === null) window.localStorage.removeItem(storageKey);
else window.localStorage.setItem(storageKey, previousStorage);
for (const [key, descriptor] of globals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
assert.equal(networkAttempts, 0);
assert.ok(subscriptions.every((entry) => entry.closed));
});
service.nostrClient = {
pubkey: null,
getActiveVideos: () => cached,
getLatestCachedCreatedAt: () => cached[0]?.created_at || 0,
clampVideoRequestLimit: (limit) => limit,
subscribeVideos(callback, options) {
receive = callback;
const subscription = { closed: false, unsub() { this.closed = true; } };
subscriptions.push(subscription);
return subscription;
},
};
service.accessControl = {
isHydrated: () => true,
ensureReady: async () => {},
canAccess: (entry) => entry.denied !== true,
};
return {
service,
events,
subscriptions,
currentReceiver: () => receive,
receive: (updates) => receive(updates),
persisted: () => JSON.parse(window.localStorage.getItem(storageKey)),
};
}
test("cold feed consumer receives full ordered eligible snapshots after live arrivals", async (t) => {
const f = fixture(t);
const notifications = [];
const initial = await f.service.loadVideos({
blacklistedEventIds: new Set(["blocked-event"]),
isAuthorBlocked: (pubkey) => pubkey === "b".repeat(64),
onVideos: (videos, detail) => notifications.push({ ids: ids(videos), ...detail }),
});
assert.deepEqual(initial, []);
assert.deepEqual(notifications, [{ ids: [], reason: "cache" }]);
f.receive([
video("older", 10), video("newer", 30),
video("private", 40, { isPrivate: true }),
video("denied", 50, { denied: true }),
video("blocked-event", 60),
video("blocked-author", 70, { pubkey: "b".repeat(64) }),
]);
f.receive([video("middle", 20)]);
assert.deepEqual(ids(Array.from(f.service.getVideosMap().values())), ["older", "newer", "middle"]);
assert.deepEqual(ids(f.persisted()), ["newer", "middle", "older"]);
assert.deepEqual(notifications, [
{ ids: [], reason: "cache" },
{ ids: ["newer", "older"], reason: "subscription" },
{ ids: ["newer", "middle", "older"], reason: "subscription" },
]);
assert.deepEqual(f.events.map((entry) => ids(entry.videos)), notifications.map((entry) => entry.ids));
});
test("a reused live subscription notifies only the latest feed consumer", async (t) => {
const f = fixture(t);
const first = [];
const latest = [];
await f.service.loadVideos({ onVideos: (videos, detail) => first.push({ ids: ids(videos), ...detail }) });
await f.service.loadVideos({ onVideos: (videos, detail) => latest.push({ ids: ids(videos), ...detail }) });
f.receive([video("live", 10)]);
assert.equal(f.subscriptions.length, 1, "feed switch reuses the live relay subscription");
assert.deepEqual(first, [{ ids: [], reason: "cache" }]);
assert.deepEqual(latest, [{ ids: [], reason: "cache" }, { ids: ["live"], reason: "subscription" }]);
await f.service.loadVideos();
const beforeDetach = latest.length;
f.receive([video("event-only", 20)]);
assert.equal(latest.length, beforeDetach, "an event-only load detaches the prior feed consumer");
assert.deepEqual(ids(f.events.at(-1).videos), ["event-only", "live"]);
});
test("force-fetch and clear invalidate late callbacks from replaced subscriptions", async (t) => {
const f = fixture(t);
const first = [];
const current = [];
await f.service.loadVideos({ onVideos: (videos) => first.push(ids(videos)) });
const obsolete = f.currentReceiver();
await f.service.loadVideos({ forceFetch: true, onVideos: (videos) => current.push(ids(videos)) });
assert.equal(f.subscriptions.length, 2);
assert.equal(f.subscriptions[0].closed, true);
obsolete([video("obsolete", 10)]);
assert.equal(f.service.getVideosMap().has("obsolete"), false);
assert.deepEqual(first, [[]]);
f.receive([video("current", 20)]);
assert.deepEqual(current, [[], ["current"]]);
const afterClear = f.currentReceiver();
f.service.clearVideoSubscription();
const eventsBefore = f.events.length;
afterClear([video("late-after-clear", 30)]);
assert.equal(f.events.length, eventsBefore);
assert.deepEqual(current, [[], ["current"]]);
assert.deepEqual(ids(f.persisted()), ["current"]);
});
test("deletion-only and mixed live updates notify reduced and empty feed snapshots", async (t) => {
const first = video("first", 10);
const second = video("second", 20);
const f = fixture(t, { cached: [second, first] });
const notifications = [];
await f.service.loadVideos({ onVideos: (videos, detail) => notifications.push({ ids: ids(videos), ...detail }) });
const deletedFirst = { ...first, deleted: true };
f.receive([deletedFirst]);
assert.equal(f.service.getVideosMap().has("first"), false);
assert.deepEqual(f.events.at(-1).deleted, [deletedFirst]);
assert.deepEqual(ids(f.persisted()), ["second"]);
const third = video("third", 30);
const deletedSecond = { ...second, deleted: true };
f.receive([deletedSecond, third]);
assert.deepEqual(f.events.at(-1).deleted, [deletedSecond]);
assert.deepEqual(ids(f.persisted()), ["third"]);
f.receive([{ ...third, deleted: true }]);
assert.equal(f.service.getVideosMap().size, 0);
assert.deepEqual(f.persisted(), []);
assert.deepEqual(notifications, [
{ ids: ["second", "first"], reason: "cache" },
{ ids: ["second"], reason: "subscription" },
{ ids: ["third"], reason: "subscription" },
{ ids: [], reason: "subscription" },
]);
});
test("throwing live consumer does not lose service events, persistence or later notifications", async (t) => {
const f = fixture(t);
const notifications = [];
await f.service.loadVideos({ onVideos(videos, detail) {
notifications.push({ ids: ids(videos), ...detail });
if (detail.reason === "subscription" && videos.length === 1) throw new Error("consumer failed");
} });
assert.doesNotThrow(() => f.receive([video("first", 10)]));
assert.deepEqual(ids(f.events.at(-1).videos), ["first"]);
assert.deepEqual(ids(f.persisted()), ["first"]);
f.receive([video("second", 20)]);
assert.deepEqual(notifications.at(-1), { ids: ["second", "first"], reason: "subscription" });
assert.deepEqual(ids(f.events.at(-1).videos), ["second", "first"]);
assert.deepEqual(ids(f.persisted()), ["second", "first"]);
});
test("warm persistent feed keeps optimistic notification without an empty cache flash", async (t) => {
const f = fixture(t, { persisted: [video("warm", 10)] });
const notifications = [];
const initial = await f.service.loadVideos({ onVideos: (videos, detail) => notifications.push({ ids: ids(videos), ...detail }) });
assert.deepEqual(ids(initial), ["warm"]);
assert.deepEqual(notifications, [{ ids: ["warm"], reason: "persistent-cache" }]);
f.receive([video("new", 20)]);
assert.deepEqual(notifications.at(-1), { ids: ["new", "warm"], reason: "subscription" });
assert.deepEqual(ids(f.persisted()), ["new", "warm"]);
});
test("absent consumer and empty or ineligible updates preserve notification and cache contracts", async (t) => {
const f = fixture(t);
await f.service.loadVideos();
f.receive(null);
f.receive([]);
f.receive([video("private", 10, { isPrivate: true }), video("denied", 20, { denied: true })]);
assert.equal(f.events.length, 1);
assert.equal(f.service.getVideosMap().size, 0);
assert.deepEqual(f.persisted(), []);
assert.doesNotThrow(() => f.receive([video("public", 30)]));
assert.deepEqual(ids(f.events.at(-1).videos), ["public"]);
assert.deepEqual(ids(f.persisted()), ["public"]);
});
@@ -0,0 +1,7 @@
# Item7
- [x] Sync/claim;currentHEAD original failure+eligibility/persistence controls.
- [x] Latest-consumer/feed-switch and force/clear controls;minimal service-owned notification token.
- [x] Seven new scenarios,nine isolated failing runtime mutants,focused5files27passing scenarios,lint11/11.
- [ ] Explicit fullslot,commit/merge+mergedfocused/lint,one unchanged normal gate/durable landed+ancestry.
- [ ] QA actual-app retest/dispatch preview handoff;close backlog and IDLE.