From 28fef689ecee93658a9da82a51a5dab492299d4c Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 23 Aug 2026 02:44:45 +0700 Subject: [PATCH 1/3] #172: three more owner-notice conditions: no-browser-route, sidecar-down, adapter-broken FaultsFrom judges three more faults at the shared twelve-hour OwnerWindow: a no-browser-route Site's refusing run (browser-backed Sites excluded), a sidecar no Lane has reached (one site-wide fault), and a Site where more than half of Series hold an old no-chapter failure row. recordPass fills the inputs from three new store reads (RefusingSince, SidecarOK, NoChapterShare), each failing independently and never failing a pass, and the clear loop forgets the empty-Site row too so a lifted sidecar-down fires again on return. --- backend/internal/latest/faults.go | 117 ++++++- backend/internal/latest/poller.go | 71 +++- backend/internal/latest/poller_test.go | 431 +++++++++++++++++++++++++ backend/internal/store/store.go | 95 ++++++ backend/internal/store/store_test.go | 159 +++++++++ 5 files changed, 853 insertions(+), 20 deletions(-) diff --git a/backend/internal/latest/faults.go b/backend/internal/latest/faults.go index 88c810f..a7e6531 100644 --- a/backend/internal/latest/faults.go +++ b/backend/internal/latest/faults.go @@ -14,6 +14,25 @@ import ( // words (no-browser-route, sidecar-down, adapter-broken); this ticket // declares only the stall. const ConditionStall = "stall" +// ConditionNoBrowserRoute is the owner-notice machine word for a Site whose +// challenge refuses for longer than the owner window with no browser route +// to clear it — the 403-with-interstitial the reader maps to +// errChallengeHeld (read.go), which plain TLS cannot clear. The word is the +// message's footer and its suppression key; it is wire-stable. +const ConditionNoBrowserRoute = "no-browser-route" + +// ConditionSidecarDown is the owner-notice machine word for a browser +// sidecar no Lane has reached for longer than the owner window: every +// browser-backed Lane's latest pass is a sidecar skip. The word is the +// message's footer and its suppression key; it is wire-stable, and its +// suppression row holds the empty Site (AC4). +const ConditionSidecarDown = "sidecar-down" + +// ConditionAdapterBroken is the owner-notice machine word for a Site whose +// adapter stopped finding chapters: more than half of its Series hold an +// old no-chapter failure row. The word is the message's footer and its +// suppression key; it is wire-stable. +const ConditionAdapterBroken = "adapter-broken" // OwnerWindow is the class-level staleness boundary every owner-notice // condition measures against — the same twelve hours the Lanes page's @@ -33,6 +52,22 @@ type Fault struct { // conditions can add inputs without changing either caller. type FaultInput struct { Passes []store.LanePass + // RefusingSince is, per Site, the unix ms when that Site's current + // unbroken run of refusing passes began, or absent when its latest pass + // did not refuse. Its zero value is an empty map, which contributes no + // fault. + RefusingSince map[string]int64 + + // SidecarOK is, per browser-backed Site, the unix ms of that Site's most + // recent pass that actually reached the sidecar. Zero when the pass log + // holds none — an asleep Lane never reached it and never counts as + // evidence either way. Its zero value is an empty map. + SidecarOK map[string]int64 + + // NoChapterShare is, per Site, the share of that Site's Series holding a + // no-chapter failure row older than the owner window. Its zero value is + // an empty map, which contributes no fault. + NoChapterShare map[string]float64 } // FaultsFrom judges the owner-notice conditions from durable rows alone, so @@ -42,12 +77,27 @@ type FaultInput struct { // value and no refusal — exactly the row the mid-loop browser loss writes // (see the comment at the outcomeUnreachable return in runLanePass), so a // Lane that owed Polls, made none, and has nothing to say for it is a fault. -// The no-refusal clause is AC6's amendment: the twice-refused break happens -// inside the pass loop, not on an early return, so a newly-gated Site would -// otherwise send two messages. A pause is excluded by Skip == "" (SkipPaused): -// the owner's own act is not reported back (AC10). The episode began at the -// pass that produced the row; the stall test itself has no age clause — the -// class-level twelve hours belongs to #172's conditions. +// The three #172 conditions share the same OwnerWindow boundary and the same +// fail-open shape: an input's absence contributes no fault, never a false +// one. +// +// - no-browser-route: a Site whose refusing run began before the window +// and that has no browser route to clear the challenge (AC2). Both +// refusal shapes count — the gate's skip='refusing' rows and the loop's +// refused>0 rows (the twice-refused break writes skip="") — so the run +// stays unbroken across them, and one healthy pass ends it. +// - sidecar-down: every browser-backed Site's latest pass is a sidecar +// skip — SkipSidecarDown or SkipNoFetcher, the two early returns that +// record a skip value — and each Site's most recent sidecar-reaching +// pass is older than the window (AC4). The skip clause is what keeps +// SkipAsleep out: a Lane under both wake thresholds is the commonest +// healthy state, never reached the sidecar, and would otherwise age into +// a false alarm. Emitted once, with Site "" (AC4's one row per episode). +// - adapter-broken: more than half of one Site's Series hold a no-chapter +// failure row older than the window (AC6). Strictly above half: the +// filter is the tool, and one Site change makes hundreds of rows, so a +// single failing Series never fires (AC7). The episode's age is the +// window — the old rows prove the episode is at least that old. func FaultsFrom(in FaultInput, now time.Time) []Fault { var faults []Fault for _, p := range in.Passes { @@ -55,9 +105,50 @@ func FaultsFrom(in FaultInput, now time.Time) []Fault { faults = append(faults, Fault{Condition: ConditionStall, Site: p.Site, Since: p.RanAt}) } } + cutoff := now.Add(-OwnerWindow).UnixMilli() + for site, since := range in.RefusingSince { + if since < cutoff && !isBrowserSite(site) { + faults = append(faults, Fault{Condition: ConditionNoBrowserRoute, Site: site, Since: since}) + } + } + if since, down := sidecarDownSince(in.Passes, in.SidecarOK, cutoff); down { + faults = append(faults, Fault{Condition: ConditionSidecarDown, Site: "", Since: since}) + } + for site, share := range in.NoChapterShare { + if share > 0.5 { + faults = append(faults, Fault{Condition: ConditionAdapterBroken, Site: site, Since: now.Add(-OwnerWindow).UnixMilli()}) + } + } return faults } +// sidecarDownSince reports whether no browser Lane has reached the sidecar +// for longer than the owner window and, when it has, the last moment any +// Lane reached it. The skip clause — every browser-backed Site's latest pass +// must be SkipSidecarDown or SkipNoFetcher — keeps SkipAsleep out (see +// FaultsFrom). A Site with no pass row at all is not judged down either: a +// fresh database is not a dead sidecar. +func sidecarDownSince(passes []store.LanePass, ok map[string]int64, cutoff int64) (since int64, down bool) { + latest := make(map[string]store.LanePass, len(passes)) + for _, p := range passes { + latest[p.Site] = p + } + for _, site := range browserBackedSites() { + p, found := latest[site] + if !found || (p.Skip != SkipSidecarDown && p.Skip != SkipNoFetcher) { + return 0, false + } + reached := ok[site] + if reached > 0 && reached >= cutoff { + return 0, false + } + if reached > since { + since = reached + } + } + return since, true +} + // Notifier delivers one owner notice. The poller neither retries nor queues: // an error is logged and the suppression row left unwritten, so the next pass // tries again while the condition holds. @@ -69,7 +160,7 @@ type Notifier interface { // clear loop in recordPass: a condition absent from a pass's fault list // forgets its episode, so the next occurrence sends again. #172 extends the // list when it adds its conditions. -var ownerNoticeConditions = []string{ConditionStall} +var ownerNoticeConditions = []string{ConditionStall, ConditionNoBrowserRoute, ConditionSidecarDown, ConditionAdapterBroken} // noticeFor renders one fault's message: the description sentence — condition, // age, repair — and the deep link the embed's title points at. Each condition @@ -80,6 +171,18 @@ func noticeFor(f Fault, row store.LanePass, now time.Time) (sentence, href strin return fmt.Sprintf( "%s owed %d Polls and made none — %s; check the Lane's browser sidecar and the Site's challenge state", f.Site, row.Due, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes" + case ConditionNoBrowserRoute: + return fmt.Sprintf( + "%s has refused for %s with no browser route — the challenge does not clear on plain TLS; redeploy or add a browser route", + f.Site, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes" + case ConditionSidecarDown: + return fmt.Sprintf( + "no browser Lane has reached the sidecar for %s — the browser sidecar is down; start or repair the browser machine", + humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes" + case ConditionAdapterBroken: + return fmt.Sprintf( + "more than half of %s's Series have failed no-chapter reads for at least %s — the Site's layout changed and the adapter is broken", + f.Site, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes" } return "", "" } diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index f43d4b3..d6da47a 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -568,18 +568,50 @@ func (p *Poller) recordPass(ctx context.Context, rec passRecord, fig passFigures // ownerNotices judges the owner-notice conditions for the pass just recorded // and fires (issue #171). It sits in recordPass because that deferred call is -// the one place every return path passes through: two of the four conditions -// occur on early returns and the success path can never see them. Per fault: -// NoticeSent → send → MarkNoticeSent, so a fault lasting a month sends one -// message, not one per pass; a condition absent from this pass's fault list -// forgets its episode, so the next occurrence sends again. Everything here is -// best-effort: a failed send, a failed store read and a failed notice write -// are all logged and never change the pass's outcome counts or its return -// value. The clear runs even when Notify is nil, so a deployment that turns -// the webhook off does not leave stale rows that suppress the first real -// notice after it is turned back on. +// the one place every return path passes through: three of the four +// conditions occur on early returns and the success path can never see them. +// The judgement reads the whole pass log plus three derived reads — the +// other Lanes' latest passes, each Site's refusing-run start, its last +// sidecar-reaching pass, and its no-chapter share — so one pass judges every +// condition (issue #172). Per fault: NoticeSent → send → MarkNoticeSent, so +// a fault lasting a month sends one message, not one per pass; a condition +// absent from this pass's fault list forgets its episode, so the next +// occurrence sends again. Everything here is best-effort: a failed send, a +// failed store read and a failed notice write are all logged and never +// change the pass's outcome counts or its return value. The clear runs even +// when Notify is nil, so a deployment that turns the webhook off does not +// leave stale rows that suppress the first real notice after it is turned +// back on. func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) { - faults := FaultsFrom(FaultInput{Passes: []store.LanePass{row}}, p.Now()) + now := p.Now() + in := FaultInput{Passes: []store.LanePass{row}} + // Each read fails independently: a failure logs and contributes no fault, + // never a false one. + if passes, err := p.Store.LatestLanePasses(); err != nil { + log.Printf("latest poll %s: latest lane passes: %v", row.Site, err) + } else { + in.Passes = passes + } + if since, err := p.Store.RefusingSince(now.UnixMilli()); err != nil { + log.Printf("latest poll %s: refusing since: %v", row.Site, err) + } else { + in.RefusingSince = since + } + if ok, err := p.Store.SidecarOK(browserBackedSites()); err != nil { + log.Printf("latest poll %s: sidecar ok: %v", row.Site, err) + } else { + in.SidecarOK = ok + } + if share, err := p.Store.NoChapterShare(now.Add(-OwnerWindow).UnixMilli()); err != nil { + log.Printf("latest poll %s: no-chapter share: %v", row.Site, err) + } else { + in.NoChapterShare = share + } + faults := FaultsFrom(in, now) + bySite := make(map[string]store.LanePass, len(in.Passes)) + for _, pass := range in.Passes { + bySite[pass.Site] = pass + } for _, f := range faults { if p.Notify == nil { continue @@ -592,14 +624,20 @@ func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) { if sent { continue } - sentence, href := noticeFor(f, row, p.Now()) + // The fault's own Site's pass renders its sentence — a stall judged + // from another Lane's pass must not quote this pass's figures. + pass, ok := bySite[f.Site] + if !ok { + pass = row + } + sentence, href := noticeFor(f, pass, now) if err := p.Notify.Notify(ctx, f, sentence, href); err != nil { // The stamp stays unset: no queue, no backoff — the condition is // durable, so the next pass tries again while it holds. log.Printf("latest poll %s: owner notice %s: %v", row.Site, f.Condition, err) continue } - if err := p.Store.MarkNoticeSent(f.Condition, f.Site, p.Now().UnixMilli()); err != nil { + if err := p.Store.MarkNoticeSent(f.Condition, f.Site, now.UnixMilli()); err != nil { log.Printf("latest poll %s: mark notice sent: %v", row.Site, err) } } @@ -609,6 +647,13 @@ func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) { log.Printf("latest poll %s: clear owner notice: %v", row.Site, err) } } + // The site-wide conditions suppress under the empty Site; clear that + // row too, so a lifted sidecar-down fires again when it returns. + if !hasFault(faults, cond, "") { + if err := p.Store.ClearNotice(cond, ""); err != nil { + log.Printf("latest poll %s: clear owner notice: %v", row.Site, err) + } + } } } diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index f7aff16..db8bf86 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -3181,6 +3181,437 @@ func TestFaultsFromStall(t *testing.T) { } } +// The #172 conditions are judged from the durable inputs alone, like the +// stall. A refusal is only a fault when the Site has no browser route to +// clear it (AC2's "browser Site" exclusions), and only once it is older than +// the owner window. +func TestFaultsFromNoBrowserRoute(t *testing.T) { + now := time.UnixMilli(5_000_000_000) + old := now.Add(-2 * OwnerWindow).UnixMilli() // a two-day refusal + fresh := now.Add(-OwnerWindow / 2).UnixMilli() // inside the window + tests := []struct { + name string + in FaultInput + want int + }{ + { + name: "plain-TLS Sites refusing for two days are faults", + in: FaultInput{RefusingSince: map[string]int64{"asura": old, "demonic": now.Add(-3 * OwnerWindow).UnixMilli()}}, + want: 2, + }, + { + name: "a browser-backed Site's refusal is a route it has, not a fault", + in: FaultInput{RefusingSince: map[string]int64{"comix": old, "kagane": old, "novelfull": old}}, + want: 0, + }, + { + name: "a fresh refusal is not old enough", + in: FaultInput{RefusingSince: map[string]int64{"asura": fresh}}, + want: 0, + }, + { + name: "no refusal is no fault", + in: FaultInput{}, + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + faults := FaultsFrom(tt.in, now) + if got := len(faults); got != tt.want { + t.Fatalf("FaultsFrom = %+v, want %d fault(s)", faults, tt.want) + } + for _, f := range faults { + if f.Condition != ConditionNoBrowserRoute || f.Site == "" || f.Since != tt.in.RefusingSince[f.Site] { + t.Fatalf("fault = %+v, want no-browser-route on the refusing Site since its run began", f) + } + } + }) + } +} + +// sidecar-down is one site-wide fault: every browser Lane's latest pass must +// be a sidecar skip (SkipSidecarDown or SkipNoFetcher — the skip clause keeps +// an asleep Lane out) and each Lane's most recent sidecar-reaching pass must +// be older than the window. The fault's Since is the last moment any Lane +// reached the sidecar. +func TestFaultsFromSidecarDown(t *testing.T) { + now := time.UnixMilli(5_000_000_000) + oldReach := now.Add(-2 * OwnerWindow).UnixMilli() + skip := func(site string) store.LanePass { + return store.LanePass{Site: site, RanAt: oldReach, Skip: SkipSidecarDown} + } + allSkipped := []store.LanePass{skip("comix"), skip("kagane"), skip("novelfull")} + reached := now.Add(-OwnerWindow / 2).UnixMilli() // inside the window + tests := []struct { + name string + in FaultInput + want int + wantSince int64 + }{ + { + name: "every browser Lane sidecar-skipped past the window is one fault", + in: FaultInput{Passes: allSkipped, SidecarOK: map[string]int64{"comix": oldReach}}, + want: 1, + wantSince: oldReach, + }, + { + name: "a browser Lane asleep for two days sends nothing", + in: FaultInput{ + Passes: []store.LanePass{ + skip("comix"), + {Site: "kagane", RanAt: oldReach, Skip: SkipAsleep}, + skip("novelfull"), + }, + }, + want: 0, + }, + { + name: "a Lane that reached the sidecar inside the window is not down", + in: FaultInput{ + Passes: allSkipped, + SidecarOK: map[string]int64{"comix": reached, "kagane": reached, "novelfull": reached}, + }, + want: 0, + }, + { + name: "a browser Site with no pass row is not judged down", + in: FaultInput{ + Passes: []store.LanePass{skip("comix"), skip("kagane")}, + }, + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + faults := FaultsFrom(tt.in, now) + if got := len(faults); got != tt.want { + t.Fatalf("FaultsFrom = %+v, want %d fault(s)", faults, tt.want) + } + for _, f := range faults { + if f.Condition != ConditionSidecarDown || f.Site != "" || f.Since != tt.wantSince { + t.Fatalf("fault = %+v, want one site-wide sidecar-down since %d", f, tt.wantSince) + } + } + }) + } +} + +// adapter-broken fires strictly above half: a float share judges a +// four-Series Site and a 200-Series Site on the same scale, exactly half +// stays quiet, and a single failing Series never reaches it. +func TestFaultsFromAdapterBroken(t *testing.T) { + now := time.UnixMilli(5_000_000_000) + tests := []struct { + name string + in FaultInput + want int + }{ + { + name: "three of four Series failing is a fault", + in: FaultInput{NoChapterShare: map[string]float64{"asura": 3.0 / 4.0}}, + want: 1, + }, + { + name: "exactly half is not a fault", + in: FaultInput{NoChapterShare: map[string]float64{"asura": 1.0 / 2.0}}, + want: 0, + }, + { + name: "one of two hundred is not a fault", + in: FaultInput{NoChapterShare: map[string]float64{"asura": 1.0 / 200.0}}, + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + faults := FaultsFrom(tt.in, now) + if got := len(faults); got != tt.want { + t.Fatalf("FaultsFrom = %+v, want %d fault(s)", faults, tt.want) + } + for _, f := range faults { + if f.Condition != ConditionAdapterBroken || f.Site != "asura" || f.Since != now.Add(-OwnerWindow).UnixMilli() { + t.Fatalf("fault = %+v, want adapter-broken on asura since the window", f) + } + } + }) + } +} +// seedRefusingRun writes a refusing pass and the gate row that follows it, +// so a Site's run of refusing passes begins at start. +func seedRefusingRun(t *testing.T, s *store.Store, site string, start time.Time) { + t.Helper() + for i, row := range []store.LanePass{ + {Site: site, RanAt: start.UnixMilli(), Skip: "", Refused: 2}, + {Site: site, RanAt: start.Add(time.Minute).UnixMilli(), Skip: SkipRefusing}, + } { + if err := s.RecordLanePass(row, -1); err != nil { + t.Fatalf("seed refusing run %d: %v", i, err) + } + } +} + +// seedSidecarSkips writes one sidecar-skipped pass for every browser-backed +// Lane, all at the same time, so the pass log shows a sidecar that has been +// unreachable since then. +func seedSidecarSkips(t *testing.T, s *store.Store, at time.Time) { + t.Helper() + for _, site := range browserBackedSites() { + if err := s.RecordLanePass(store.LanePass{Site: site, RanAt: at.UnixMilli(), Skip: SkipSidecarDown}, -1); err != nil { + t.Fatalf("seed sidecar skip %s: %v", site, err) + } + } +} + +// seedNoChapter writes an old no-chapter failure row for each Series id. +func seedNoChapter(t *testing.T, s *store.Store, site string, ids []string, failingSince int64) { + t.Helper() + for _, id := range ids { + if err := s.RecordSeriesFailure(site, id, "no_chapter", failingSince); err != nil { + t.Fatalf("seed no-chapter failure %s:%s: %v", site, id, err) + } + } +} + +// no-browser-route fires once while the refusal holds, and again after it +// lifts and returns: the suppression row is the whole state, the gate row of +// a second refusing pass is the same episode, a healthy pass in between +// breaks the run and clears the row, and a later run that has aged past the +// window sends again (AC1, AC9). +func TestOwnerNoticeNoBrowserRouteFiresOncePerEpisode(t *testing.T) { + now := time.UnixMilli(5_000_000_000) + s, _ := newTestStore(t) + seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0) + seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0) + refusing := &fakeFetcher{status: 403} + notifier := &fakeNotifier{} + p := newTestPoller(t, s, refusing, now) + p.Now = func() time.Time { return now } + p.Notify = notifier + + // A run that began before the window: seed two-day-old refusing rows, + // then a fresh pass that refuses again — the run is unbroken and still + // old, so the owner is told once. + seedRefusingRun(t, s, "asura", now.Add(-2*OwnerWindow)) + p.runLanePass(context.Background(), "asura", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after first refusing pass = %d, want 1", got) + } + if f := notifier.fault(0); f.Condition != ConditionNoBrowserRoute || f.Site != "asura" || f.Since != now.Add(-2*OwnerWindow).UnixMilli() { + t.Fatalf("fault = %+v, want no-browser-route on asura since the run began", f) + } + if sent, err := s.NoticeSent(ConditionNoBrowserRoute, "asura"); err != nil || !sent { + t.Fatalf("NoticeSent after first refusing pass = %v, %v; want true, nil", sent, err) + } + + // A second refusing pass — the refusal gate now returns early — is the + // same episode: the gate row continues the run, no second message. + now = now.Add(time.Minute) + p.runLanePass(context.Background(), "asura", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after gated refusing pass = %d, want 1 (one per episode)", got) + } + + // A healthy pass in between breaks the run: the condition lifts and the + // suppression row is cleared. + now = now.Add(RefuseBackoff + time.Minute) + seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0) + seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0) + p.Fetch = &fakeFetcher{body: asuraSeriesFixture, status: 200} + p.runLanePass(context.Background(), "asura", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after healthy pass = %d, want 1 (a healthy pass sends nothing)", got) + } + if sent, err := s.NoticeSent(ConditionNoBrowserRoute, "asura"); err != nil || sent { + t.Fatalf("NoticeSent after healthy pass = %v, %v; want false, nil (row cleared)", sent, err) + } + + // A later run that has aged past the window again is a new episode. + runStart := now.Add(2 * OwnerWindow) + seedRefusingRun(t, s, "asura", runStart) + now = runStart.Add(2 * OwnerWindow) + seedForCheck(t, s, "asura:r1", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0) + seedForCheck(t, s, "asura:r2", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0) + p.Fetch = refusing + p.runLanePass(context.Background(), "asura", false) + if got := notifier.callCount(); got != 2 { + t.Fatalf("notices after the returned refusal = %d, want 2 (a new episode)", got) + } + if f := notifier.fault(1); f.Condition != ConditionNoBrowserRoute || f.Site != "asura" || f.Since != runStart.UnixMilli() { + t.Fatalf("fault = %+v, want no-browser-route on asura since the new run began", f) + } +} + +// A browser-backed Site's refusal sends nothing (AC2): it has a route, so a +// two-day refusal is a browser-side problem, not the no-browser-route fault. +func TestOwnerNoticeBrowserRefusalSendsNothing(t *testing.T) { + now := time.UnixMilli(5_000_000_000) + s, _ := newTestStore(t) + for i := 1; i <= 6; i++ { + seedForCheck(t, s, fmt.Sprintf("comix:b%d", i), fmt.Sprintf("https://comix.to/title/b%d", i), 0) + } + notifier := &fakeNotifier{} + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.Now = func() time.Time { return now } + p.BrowserFetch = &fakeFetcher{status: 403} + p.Notify = notifier + + seedRefusingRun(t, s, "comix", now.Add(-2*OwnerWindow)) + p.runLanePass(context.Background(), "comix", false) + if got := notifier.callCount(); got != 0 { + t.Fatalf("notices = %d, want 0 (a browser Site's refusal is not a no-browser-route)", got) + } + if sent, err := s.NoticeSent(ConditionNoBrowserRoute, "comix"); err != nil || sent { + t.Fatalf("NoticeSent = %v, %v; want false, nil", sent, err) + } +} + +// sidecar-down fires once while no Lane reaches the sidecar, and again after +// it returns and dies again (AC4, AC9): the suppression row holds the empty +// Site, the first Lane to notice writes it and the others stay quiet, a +// healthy browser pass clears it, and a later outage is a new episode. +func TestOwnerNoticeSidecarDownFiresOncePerEpisode(t *testing.T) { + now := time.UnixMilli(5_000_000_000) + s, _ := newTestStore(t) + notifier := &fakeNotifier{} + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.Now = func() time.Time { return now } + p.Notify = notifier + + // Seed the pass log so every browser Lane's latest pass is a sidecar + // skip older than the window, then a fresh pass that skips too. + seedSidecarSkips(t, s, now.Add(-2*OwnerWindow)) + p.setBrowserDown(now) + p.runLanePass(context.Background(), "comix", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after first sidecar-skipped pass = %d, want 1", got) + } + if f := notifier.fault(0); f.Condition != ConditionSidecarDown || f.Site != "" { + t.Fatalf("fault = %+v, want one site-wide sidecar-down", f) + } + if sent, err := s.NoticeSent(ConditionSidecarDown, ""); err != nil || !sent { + t.Fatalf("NoticeSent after first pass = %v, %v; want true, nil", sent, err) + } + + // Another Lane's sidecar-skipped pass is the same episode: still one + // message. + p.runLanePass(context.Background(), "kagane", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after another Lane's skipped pass = %d, want 1 (one per episode)", got) + } + + // A healthy browser pass reaches the sidecar: the condition lifts and the + // suppression row is cleared. Five due Series wake the browser, and the + // series ids carry the fixture's id prefix so the scoped reader finds its + // chapters. + now = now.Add(RefuseBackoff + time.Minute) + for i := 1; i <= 5; i++ { + seedForCheck(t, s, fmt.Sprintf("comix:h%d", i), "https://comix.to/title/n8we-dungeons-and-crayons", 0) + } + p.BrowserFetch = &fakeFetcher{body: comixSeriesFixture, status: 200} + p.runLanePass(context.Background(), "comix", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after healthy pass = %d, want 1 (a healthy pass sends nothing)", got) + } + if sent, err := s.NoticeSent(ConditionSidecarDown, ""); err != nil || sent { + t.Fatalf("NoticeSent after healthy pass = %v, %v; want false, nil (row cleared)", sent, err) + } + + // The sidecar dies again: a new episode, told again. + now = now.Add(2 * OwnerWindow) + p.setBrowserDown(now) + seedSidecarSkips(t, s, now.Add(-time.Minute)) + p.runLanePass(context.Background(), "comix", false) + if got := notifier.callCount(); got != 2 { + t.Fatalf("notices after the returned outage = %d, want 2 (a new episode)", got) + } +} + +// A browser Lane asleep under both wake thresholds sends nothing: it never +// reached the sidecar, but its skip is not a sidecar skip, so it cannot age +// into a false alarm (AC9). +func TestOwnerNoticeSidecarDownAsleepSendsNothing(t *testing.T) { + now := time.UnixMilli(5_000_000_000) + s, _ := newTestStore(t) + notifier := &fakeNotifier{} + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.Now = func() time.Time { return now } + p.Notify = notifier + + seedSidecarSkips(t, s, now.Add(-2*OwnerWindow)) + if err := s.RecordLanePass(store.LanePass{Site: "kagane", RanAt: now.Add(-2*OwnerWindow + time.Minute).UnixMilli(), Skip: SkipAsleep}, -1); err != nil { + t.Fatalf("seed asleep pass: %v", err) + } + p.setBrowserDown(now) + p.runLanePass(context.Background(), "comix", false) + if got := notifier.callCount(); got != 0 { + t.Fatalf("notices = %d, want 0 (an asleep Lane is not a down sidecar)", got) + } + if sent, err := s.NoticeSent(ConditionSidecarDown, ""); err != nil || sent { + t.Fatalf("NoticeSent = %v, %v; want false, nil", sent, err) + } +} + +// adapter-broken fires once while more than half of a Site's Series hold an +// old no-chapter failure row, and again after the failures clear and return: +// the suppression row is the whole state, a pass that clears the failures +// forgets the episode, and a later return sends again. The share is judged +// from durable rows, so the pass itself may be healthy (AC6, AC9). +func TestOwnerNoticeAdapterBrokenFiresOncePerEpisode(t *testing.T) { + now := time.UnixMilli(5_000_000_000) + s, _ := newTestStore(t) + for i := 1; i <= 4; i++ { + seedForCheck(t, s, fmt.Sprintf("asura:a%d", i), fmt.Sprintf("https://asurascans.com/comics/a%d", i), now.UnixMilli()) + } + notifier := &fakeNotifier{} + p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) + p.Now = func() time.Time { return now } + p.Notify = notifier + + // Three of four Series hold an old no-chapter row: the first pass fires + // one adapter-broken fault. + seedNoChapter(t, s, "asura", []string{"a1", "a2", "a3"}, now.Add(-2*OwnerWindow).UnixMilli()) + p.runLanePass(context.Background(), "asura", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after first pass = %d, want 1", got) + } + if f := notifier.fault(0); f.Condition != ConditionAdapterBroken || f.Site != "asura" || f.Since != now.Add(-OwnerWindow).UnixMilli() { + t.Fatalf("fault = %+v, want adapter-broken on asura since the window", f) + } + if sent, err := s.NoticeSent(ConditionAdapterBroken, "asura"); err != nil || !sent { + t.Fatalf("NoticeSent after first pass = %v, %v; want true, nil", sent, err) + } + + // A second pass is the same episode: still one message. + p.runLanePass(context.Background(), "asura", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after second pass = %d, want 1 (one per episode)", got) + } + + // The failures clear: the condition lifts and the suppression row is + // cleared. + for _, id := range []string{"a1", "a2", "a3"} { + if err := s.ClearSeriesFailure("asura", id); err != nil { + t.Fatalf("clear failure %s: %v", id, err) + } + } + now = now.Add(RefuseBackoff + time.Minute) + p.runLanePass(context.Background(), "asura", false) + if got := notifier.callCount(); got != 1 { + t.Fatalf("notices after cleared failures = %d, want 1 (a healthy share sends nothing)", got) + } + if sent, err := s.NoticeSent(ConditionAdapterBroken, "asura"); err != nil || sent { + t.Fatalf("NoticeSent after cleared failures = %v, %v; want false, nil (row cleared)", sent, err) + } + + // The failures return: a new episode, told again. + seedNoChapter(t, s, "asura", []string{"a1", "a2", "a3"}, now.Add(-2*OwnerWindow).UnixMilli()) + now = now.Add(time.Minute) + p.runLanePass(context.Background(), "asura", false) + if got := notifier.callCount(); got != 2 { + t.Fatalf("notices after the returned failures = %d, want 2 (a new episode)", got) + } +} // The stall fires exactly once while it holds, and again after it lifts and // returns: the suppression row is the whole state, so the second stall-shaped // pass is the same episode, a healthy pass in between clears the row, and the diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 2f579a3..ed32a60 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1227,6 +1227,101 @@ func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) { return out, rows.Err() } +// RefusingSince reports, per Site, when that Site's current unbroken run of +// refusing passes began: a pass refuses when the Lane gate returned early +// (skip = 'refusing') or the pass loop counted refusals (refused > 0), so a +// Site parked in refusal backoff keeps its run unbroken. A Site whose +// latest pass did not refuse is absent. The run is bounded by the pass +// log's retention — a run older than the log answers with the oldest row +// present — and nothing after now counts: the cutoff is the caller's clock. +func (s *Store) RefusingSince(now int64) (map[string]int64, error) { + rows, err := s.db.Query(` + SELECT site, MIN(ran_at) + FROM poll_passes p + WHERE ran_at <= $1 + AND (refused > 0 OR skip = 'refusing') + AND ran_at > COALESCE(( + SELECT MAX(q.ran_at) FROM poll_passes q + WHERE q.site = p.site AND q.ran_at <= $1 + AND NOT (q.refused > 0 OR q.skip = 'refusing') + ), 0) + GROUP BY site`, now) + if err != nil { + return nil, fmt.Errorf("query refusing since: %w", err) + } + defer rows.Close() + + out := map[string]int64{} + for rows.Next() { + var site string + var since int64 + if err := rows.Scan(&site, &since); err != nil { + return nil, fmt.Errorf("scan refusing since: %w", err) + } + out[site] = since + } + return out, rows.Err() +} + +// SidecarOK reports, per Site in sites, the unix ms of that Site's most +// recent pass that actually reached the sidecar: the pass ran its loop +// (skip = '') and no Series read lost Chrome (unreachable = 0) — a +// challenge answer still reached the sidecar, a lost sidecar did not. A +// Site with no such pass is absent: an asleep Lane never reached it, so it +// is absent too and cannot age into a sidecar-down alarm by itself. +func (s *Store) SidecarOK(sites []string) (map[string]int64, error) { + rows, err := s.db.Query(` + SELECT DISTINCT ON (site) site, ran_at + FROM poll_passes + WHERE site = ANY($1) AND skip = '' AND unreachable = 0 + ORDER BY site, ran_at DESC`, sites) + if err != nil { + return nil, fmt.Errorf("query sidecar ok: %w", err) + } + defer rows.Close() + + out := map[string]int64{} + for rows.Next() { + var site string + var ranAt int64 + if err := rows.Scan(&site, &ranAt); err != nil { + return nil, fmt.Errorf("scan sidecar ok: %w", err) + } + out[site] = ranAt + } + return out, rows.Err() +} + +// NoChapterShare reports, per Site, the share of that Site's Series holding +// a no-chapter failure row older than cutoff: old rows over the Site's +// whole Series count, so a four-Series Site and a 200-Series Site are +// judged on the same scale. A Site with no Series has no share and is +// absent. +func (s *Store) NoChapterShare(cutoff int64) (map[string]float64, error) { + rows, err := s.db.Query(` + SELECT s.site, + (COUNT(*) FILTER (WHERE f.outcome = 'no_chapter' AND f.failing_since < $1))::float8 + / (COUNT(*)::float8) + FROM series s + LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id + GROUP BY s.site + ORDER BY s.site`, cutoff) + if err != nil { + return nil, fmt.Errorf("query no-chapter share: %w", err) + } + defer rows.Close() + + out := map[string]float64{} + for rows.Next() { + var site string + var share float64 + if err := rows.Scan(&site, &share); err != nil { + return nil, fmt.Errorf("scan no-chapter share: %w", err) + } + out[site] = share + } + return out, rows.Err() +} // SetLaneRefusal persists a Site's refusal backoff stamp without touching its // pause. until is supplied by the caller's clock. func (s *Store) SetLaneRefusal(site string, until int64) error { diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index b70093d..5b9a3b8 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "io/fs" + "fmt" "os" "path/filepath" "strconv" @@ -2738,3 +2739,161 @@ func TestOwnerNoticeRows(t *testing.T) { t.Fatalf("ClearNotice on a missing row: %v", err) } } +// RefusingSince reads one Site's current unbroken run of refusing passes +// (issue #172). A refusing pass is one the Lane gate returned early from +// (skip 'refusing') or one whose loop counted refusals (refused > 0) — the +// two shapes a persistently-challenged Site alternates between, so the run +// must not break when the gate row follows the refusal row. A Site whose +// latest pass did not refuse is absent, nothing after the caller's clock +// counts, and a run older than the log answers with the oldest row present. +func TestRefusingSince(t *testing.T) { + s := newTestStore(t) + seed := func(site string, ranAt int64, skip string, refused int) { + t.Helper() + if err := s.RecordLanePass(LanePass{Site: site, RanAt: ranAt, Skip: skip, Refused: refused}, -1); err != nil { + t.Fatalf("RecordLanePass(%s/%d): %v", site, ranAt, err) + } + } + // asura: a refusing run broken by a healthy pass, then resumed; the + // current run began at the pass after the break. + seed("asura", 100, "", 2) + seed("asura", 200, "", 0) + seed("asura", 300, "refusing", 0) + seed("asura", 400, "", 2) + // demonic: the latest pass is healthy — no run, absent. + seed("demonic", 100, "", 2) + seed("demonic", 200, "", 0) + // novelfull: a healthy pass after now must not break the run — the run + // is judged as of the caller's clock. + seed("novelfull", 100, "", 2) + seed("novelfull", 600, "", 0) + // comix: an unbroken run reaches back to the oldest row present. + seed("comix", 100, "", 2) + seed("comix", 200, "refusing", 0) + + got, err := s.RefusingSince(450) + if err != nil { + t.Fatalf("RefusingSince: %v", err) + } + want := map[string]int64{"asura": 300, "novelfull": 100, "comix": 100} + if len(got) != len(want) { + t.Fatalf("RefusingSince = %v, want %v", got, want) + } + for site, since := range want { + if got[site] != since { + t.Fatalf("RefusingSince[%s] = %d, want %d (got %v)", site, got[site], since, got) + } + } + if _, ok := got["demonic"]; ok { + t.Fatalf("RefusingSince names demonic, whose latest pass did not refuse: %v", got) + } +} + +// SidecarOK reads, per Site, the most recent pass that actually reached the +// sidecar (issue #172): the pass ran its loop (skip '') and no read lost +// Chrome (unreachable 0). A challenge answer still reached the sidecar, a +// lost sidecar and every skip value did not, and a Site with no qualifying +// pass — an asleep Lane included — is absent. +func TestSidecarOK(t *testing.T) { + s := newTestStore(t) + seed := func(site string, ranAt int64, skip string, unreachable int) { + t.Helper() + if err := s.RecordLanePass(LanePass{Site: site, RanAt: ranAt, Skip: skip, Unreachable: unreachable}, -1); err != nil { + t.Fatalf("RecordLanePass(%s/%d): %v", site, ranAt, err) + } + } + // comix: only the skip='' unreachable=0 pass reached the sidecar; the + // mid-loop browser-loss row (skip '', unreachable > 0) and the skip + // values never did. + seed("comix", 100, "", 1) + seed("comix", 200, "sidecar-down", 0) + seed("comix", 300, "", 0) + seed("comix", 400, "refusing", 0) + // kagane: two passes reached the sidecar; the newest wins. + seed("kagane", 150, "", 0) + seed("kagane", 250, "", 0) + // novelfull: an asleep Lane never reached it. + seed("novelfull", 120, "asleep", 0) + + got, err := s.SidecarOK([]string{"comix", "kagane", "novelfull", "lightnovelworld"}) + if err != nil { + t.Fatalf("SidecarOK: %v", err) + } + want := map[string]int64{"comix": 300, "kagane": 250} + if len(got) != len(want) { + t.Fatalf("SidecarOK = %v, want %v", got, want) + } + for site, ranAt := range want { + if got[site] != ranAt { + t.Fatalf("SidecarOK[%s] = %d, want %d (got %v)", site, got[site], ranAt, got) + } + } + for _, site := range []string{"novelfull", "lightnovelworld"} { + if _, ok := got[site]; ok { + t.Fatalf("SidecarOK names %s, which never reached the sidecar: %v", site, got) + } + } +} + +// NoChapterShare reads, per Site, the share of its Series holding a +// no-chapter failure row older than the cutoff (issue #172): old rows over +// the Site's whole Series count, so a four-Series Site and a 200-Series +// Site are judged on the same scale. A fresh no-chapter row and a row of +// any other outcome do not count. +func TestNoChapterShare(t *testing.T) { + s := newTestStore(t) + for i := 1; i <= 4; i++ { + seedForCheck(t, s, fmt.Sprintf("asura:a%d", i), fmt.Sprintf("https://asurascans.com/comics/a%d", i), 0) + } + seedForCheck(t, s, "comix:c1", "https://comix.to/title/c1", 0) + seedForCheck(t, s, "comix:c2", "https://comix.to/title/c2", 0) + seedForCheck(t, s, "demonic:d1", "https://demonicscans.org/series/d1", 0) + for i := 1; i <= 200; i++ { + seedForCheck(t, s, fmt.Sprintf("novelfull:n%d", i), fmt.Sprintf("https://novelfull.com/n%d.html", i), 0) + } + + const cutoff = 1000 + oldNoChapter := func(site, seriesID string) { + t.Helper() + if err := s.RecordSeriesFailure(site, seriesID, "no_chapter", 100); err != nil { + t.Fatalf("seed no-chapter failure %s:%s: %v", site, seriesID, err) + } + } + // asura: three of four hold an old no-chapter row; the fourth's + // no-chapter row is fresh — it is not old, so it does not count. + oldNoChapter("asura", "a1") + oldNoChapter("asura", "a2") + oldNoChapter("asura", "a3") + if err := s.RecordSeriesFailure("asura", "a4", "no_chapter", 2000); err != nil { + t.Fatalf("seed fresh no-chapter failure: %v", err) + } + // comix: an old no-chapter row and an old errors row — the errors row + // is not a no-chapter row, so the share is 1/2, the exact boundary. + oldNoChapter("comix", "c1") + if err := s.RecordSeriesFailure("comix", "c2", "errors", 100); err != nil { + t.Fatalf("seed errors failure: %v", err) + } + // demonic and novelfull: one old no-chapter row each, against sites of + // one and two hundred Series. + oldNoChapter("demonic", "d1") + oldNoChapter("novelfull", "n1") + + got, err := s.NoChapterShare(cutoff) + if err != nil { + t.Fatalf("NoChapterShare: %v", err) + } + checks := []struct { + site string + want float64 + }{ + {"asura", 3.0 / 4.0}, + {"comix", 1.0 / 2.0}, + {"demonic", 1.0 / 1.0}, + {"novelfull", 1.0 / 200.0}, + } + for _, c := range checks { + if got[c.site] != c.want { + t.Fatalf("NoChapterShare[%s] = %v, want %v", c.site, got[c.site], c.want) + } + } +} From b58945894dc462c1448004dc8ecbf40076957a23 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 23 Aug 2026 02:47:21 +0700 Subject: [PATCH 2/3] #172: floor sidecar-down's age at the window when no Lane ever reached the sidecar A fresh database whose browser Lanes skipped from day one has no sidecar-reaching pass, so sidecarDownSince returned Since 0 and the notice rendered the age since the epoch. Report the window itself: 'at least twelve hours' is the honest floor the condition guarantees. --- backend/internal/latest/faults.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/internal/latest/faults.go b/backend/internal/latest/faults.go index a7e6531..2cd0852 100644 --- a/backend/internal/latest/faults.go +++ b/backend/internal/latest/faults.go @@ -146,6 +146,12 @@ func sidecarDownSince(passes []store.LanePass, ok map[string]int64, cutoff int64 since = reached } } + // No Lane's retained pass log shows a sidecar reach: the honest age is + // the window itself — "at least twelve hours" — not the epoch, which + // humanAge would render as tens of thousands of days. + if since == 0 { + since = cutoff + } return since, true } From c655586202b86e50fc0b92504a0ee7ec8f89ea20 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 23 Aug 2026 02:48:14 +0700 Subject: [PATCH 3/3] #172: clear the empty-Site suppression row only for sidecar-down Only sidecar-down ever writes a Site=='' row, so the loop's empty-Site clear for the other three conditions was a no-op delete on every pass. Move it out of the per-condition loop, scoped to ConditionSidecarDown. --- backend/internal/latest/poller.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index d6da47a..088ae49 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -647,12 +647,13 @@ func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) { log.Printf("latest poll %s: clear owner notice: %v", row.Site, err) } } - // The site-wide conditions suppress under the empty Site; clear that - // row too, so a lifted sidecar-down fires again when it returns. - if !hasFault(faults, cond, "") { - if err := p.Store.ClearNotice(cond, ""); err != nil { - log.Printf("latest poll %s: clear owner notice: %v", row.Site, err) - } + } + // sidecar-down suppresses under the empty Site — one row across all + // browser Lanes (AC4) — so clear that row when it is absent from this + // pass's fault list, and a lifted sidecar fires again when it returns. + if !hasFault(faults, ConditionSidecarDown, "") { + if err := p.Store.ClearNotice(ConditionSidecarDown, ""); err != nil { + log.Printf("latest poll %s: clear owner notice: %v", row.Site, err) } } }