Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/renderer/shared/hooks/watch/useStallRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
decideIdleNoFarmable,
decideNoProgressRecovery,
decideWatchingNoFarmable,
reconcileNoFarmableOnActiveDrop,
type NoFarmableMarker,
type StallRecoveryAction,
type WatchConfirmationProbe,
Expand Down Expand Up @@ -227,7 +228,14 @@ export function useStallRecovery({
runActions(decision.actions);
return;
}
noFarmableDropRef.current = null;
// Keep the no-farmable grace alive across a transient activeDropInfo blip
// while the target is still unwatchable; only clear it once the target is
// genuinely watchable again. Prevents an oscillating active-drop signal from
// resetting the grace forever (engine wedged in "watching, not watchable").
noFarmableDropRef.current = reconcileNoFarmableOnActiveDrop(
noFarmableDropRef.current,
canWatchTarget,
);
if (!activeDropInfo) {
watchStallTrackerRef.current = null;
watchConfirmationProbeRef.current = null;
Expand Down
44 changes: 44 additions & 0 deletions src/renderer/shared/hooks/watch/watchStallRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
NO_FARMABLE_DROP_GRACE_MS,
NO_FARMABLE_GAME_COOLDOWN_MS,
pickStallRecoveryChannel,
reconcileNoFarmableOnActiveDrop,
shouldProbeNoProgressConfirmation,
STALL_CONFIRMATION_PROBE_COOLDOWN_MS,
STALL_MAX_CHANNEL_RECOVERY_ATTEMPTS,
Expand Down Expand Up @@ -595,6 +596,49 @@ describe("decideWatchingNoFarmable", () => {
});
});

describe("reconcileNoFarmableOnActiveDrop (no-farmable wedge)", () => {
it("preserves the grace marker when an active drop blips in while the target stays unwatchable", () => {
const marker = { key: "Rust", sinceAt: 1 };
// canWatchTarget=false: a transient activeDropInfo must NOT reset the grace.
expect(reconcileNoFarmableOnActiveDrop(marker, false)).toBe(marker);
});

it("clears the marker once the target is genuinely watchable again", () => {
expect(reconcileNoFarmableOnActiveDrop({ key: "Rust", sinceAt: 1 }, true)).toBeNull();
});

it("stays null when there is no marker to preserve", () => {
expect(reconcileNoFarmableOnActiveDrop(null, false)).toBeNull();
});

it("still escalates after the grace period despite a transient active-drop blip", () => {
const start = 100_000;
// tick 1 — no active drop, target unwatchable: seed the grace marker.
const seeded = decideWatchingNoFarmable({
...watchingBase,
now: start,
noFarmable: null,
}).noFarmable;
expect(seeded).toEqual({ key: "Rust", sinceAt: start });

// tick 2 — an active drop blips in while still unwatchable; the executor
// reconciles the marker here. With the fix it must be preserved, otherwise
// the 30s grace never completes and the engine wedges in
// "watching, but target not watchable".
const afterBlip = reconcileNoFarmableOnActiveDrop(seeded, false);
expect(afterBlip).toEqual({ key: "Rust", sinceAt: start });

// tick 3 — active drop gone, grace window elapsed: give-up must fire.
const result = decideWatchingNoFarmable({
...watchingBase,
now: start + NO_FARMABLE_DROP_GRACE_MS + 1,
noFarmable: afterBlip,
});
expect(result.actions.map((a) => a.kind)).toContain("stop-watching");
expect(result.resetStallTracking).toBe(true);
});
});

const activeDrop = (over: Partial<Record<string, unknown>> = {}) =>
({
id: "d1",
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/shared/hooks/watch/watchStallRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ export type StallRecoveryAction =
/** "Watching with no farmable drop" grace-period marker (was noFarmableDropRef). */
export type NoFarmableMarker = { key: string; sinceAt: number };

/**
* Recovery-branch upkeep for the no-farmable grace marker.
*
* `useStallRecovery` enters the no-progress recovery branch whenever an
* `activeDropInfo` is present. Clearing the no-farmable grace there is only safe
* when the target is genuinely watchable again (`canWatchTarget`). While the
* target stays unwatchable, a *transient* `activeDropInfo` blip (e.g. an
* upcoming/unlinked drop surfacing for a tick) must NOT reset the grace —
* otherwise an oscillating active-drop signal keeps restarting the
* `NO_FARMABLE_DROP_GRACE_MS` window and `decideWatchingNoFarmable`'s give-up
* path never fires, wedging the engine in "watching, but target currently not
* watchable".
*/
export const reconcileNoFarmableOnActiveDrop = (
marker: NoFarmableMarker | null,
canWatchTarget: boolean,
): NoFarmableMarker | null => (canWatchTarget ? null : marker);

export type IdleNoFarmableInput = {
allowWatching: boolean;
autoSelectEnabled: boolean;
Expand Down
Loading