mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 17:02:28 +00:00
Merge branch 'unstable' into area/dispatch
This commit is contained in:
commit
1222805b2d
15 files changed
+866
-16
No files matched your search
@@ -0,0 +1 @@
|
||||
HLS-only implementation perdispatch20:55:13Z/confirmed newestRULE. Controller passes optional lifecycle; manifesthelper cancellation covers fetch+body, synchronous invalidation/promptcancelled/abort/identitysafehook+timercleanup. No engine/player/session/HEAD/media/torrent/RangeGET changes. New35tests+adjacent94=129pass/6files;19isolatedmutantsfailnaturally/cancelled0. Final lint pending; record then commit+merge currentunstable,READY for serialized full/browser/build/normal queue. No expensive stage/submission without grant.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Explicit dispatch20:55:13Z Stage3 HLS scope extension and helper/controller ownership; keep broaderitem9OPEN. Existing HEAD/media/engine/player/nativepreference behavior and standalone manifest callers preserved.
|
||||
- Pass optional lifecycle as third helper argument; helper owns its own hook/controller/timer through fetch AND response.text, races logical cancellation independently of AbortController cooperation. Native media remains on existing path.
|
||||
- Test actual observer/controller/cache/helper with controlled IO/clock boundaries, new unique suite only; existing tests/expectations unchanged. Baseline35newcases13pass/22fail before fix (isolated copies); original12mode5fail/7pass retained.
|
||||
- Mutants isolate copies in/tmp,never mutate worktree/shared modules. Initial retainedtimer mutant hit engineclearTimeout outside manifesthelper;corrected exacthelper suffix and final19allfail naturally. Formatting outside manifesthelper restored to keep engine read-only.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Bitvid scheduled-job policy
|
||||
|
||||
Owner direction, 2026-10-23: "I would like to not have extra processes like this running when the fleet is down or paused".
|
||||
|
||||
All seven installed Bitvid cron jobs enter through `tools/fleet/bin/bitvid-schedule-guard.mjs`, not directly through `sched.sh`.
|
||||
|
||||
- A missing/dead/stopped interactive fleet means no scheduled headless launch. At least one configured interactive area must have a running record and a live PID.
|
||||
- `fleet/PAUSED` is represented by the existing state-directory `PAUSED` marker. It unconditionally blocks every job, including the weekly restart job; `ignorePause` does not bypass this outer guard.
|
||||
- A running scheduled job is checked every second. On pause/down, the guard sends TERM only to the process group it created. Fleet's nested process runners forward termination to their own agent group. A bounded KILL fallback handles a group that does not exit.
|
||||
- A cron tick while down or paused starts only the brief guard check, which exits without an AI session or persistent watcher. There is no always-on supervisor added.
|
||||
- Unpausing and bringing the fleet up lets the next normal cron tick run; this policy does not automatically resume a paused fleet.
|
||||
- Existing quiet hours, per-job non-overlap locks and time limits still apply inside `sched.sh`. The one-heavy-job convention still applies.
|
||||
- This is a Bitvid-specific file, separate from the kit-managed scheduler. Kit upgrades should retain it. `fleet crontab` prints generic, unguarded commands: do not overwrite the installed guarded entries without preserving this policy.
|
||||
|
||||
Validation: `env -u NODE_TEST_CONTEXT node --test tools/fleet/test/bitvid-schedule-guard.test.mjs` passed 8 tests with natural exit; fixtures only, no real fleet shutdown/pause or headless model run. Test integrity: new behavioral coverage, no existing assertions/gates changed. Process stop tests use a disposable sleep child and verify the unrelated test parent remains alive. Nested production runner cleanup relies on the kit's existing TERM forwarding; this is not a real-model stop qualification.
|
||||
@@ -7,5 +7,5 @@
|
||||
"addedOn": "2026-10-22",
|
||||
"filedAt": "fb54cea3a2ba99b4860421724a5050af6b97c293",
|
||||
"brief": "docs/agents/CAMPAIGN-2026-10-22.md",
|
||||
"evidence": "HEAD-only fix e9c960ed/merged09f52bdd durably landed f41ac656 normalgateexit0,435discovered/435unique starts missingextra0,ancestry0;build/artifactexit0;controlled native Chromium+Firefox lifecycle PASS with synthetic loadeddata/publicdeny. Prior60focused/16mutants/lint11. Resources released. HLS/torrent/RangeGET separately OPEN; no wholeStage3 claim. test_logs/TEST_LOG_2026-10-22_20-52-00_client-reliability-9-browser.md HLS read-only atd04d501e:12modes/7controls pass/5cancellationfail/0cancelled/naturalexit1; GET/body finalowner retains timer/unsettled,rejoin old,latecache. Await explicit controller+HLShelper routing. test_logs/TEST_LOG_2026-10-22_20-54-00_client-reliability-9-hls-investigation.md"
|
||||
"evidence": "HEAD-only fix e9c960ed/merged09f52bdd durably landed f41ac656 normalgateexit0,435discovered/435unique starts missingextra0,ancestry0;build/artifactexit0;controlled native Chromium+Firefox lifecycle PASS with synthetic loadeddata/publicdeny. Prior60focused/16mutants/lint11. Resources released. HLS/torrent/RangeGET separately OPEN; no wholeStage3 claim. test_logs/TEST_LOG_2026-10-22_20-52-00_client-reliability-9-browser.md HLS read-only atd04d501e:12modes/7controls pass/5cancellationfail/0cancelled/naturalexit1; GET/body finalowner retains timer/unsettled,rejoin old,latecache. Await explicit controller+HLShelper routing. test_logs/TEST_LOG_2026-10-22_20-54-00_client-reliability-9-hls-investigation.md HLS explicitlyrouted20:55:13Z;minimal controllerhandoff+helperfetch/bodycancel implemented;new35+existing94=129pass/6files,19isolatedmutantsnaturallyfail/cancelled0,lint11exit0;noexistingtestsmodified. READY for serialized full/build/browser/normal slot after candidatecommit/merge; notsubmitted. test_logs/TEST_LOG_2026-10-22_21-03-00_client-reliability-9-hls-ready.md"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Active HLS manifest owner cancellation
|
||||
|
||||
Source: docs/performance-efficiency-plan.md Stage3, PROGRAM campaign priority2, explicit dispatch mailbox2026-10-22T20:55:13Z and newest RULE-CHANGES HLS routing. Dispatch independently reproduced actual12mode baseline7pass/5fail atd04d501e.
|
||||
|
||||
Old scoped behavior: queued/media/HEAD ownership cancellation does not reach the HLS manifest helper. Final-owner disposal retains manifest GET/body-read/timer and unsettled observer work; same-source rejoin inherits the old request and late success can cache.
|
||||
|
||||
New HLS health-probe contract: last registered owner disposal synchronously invalidates that work, requests transport abort where available and promptly settles the observer chain null even if fetch/body ignore abort or AbortController is absent. Release manifest timer and only that probe's hook once. Pre-cancelled work starts no GET. Fresh same-turn/later same-source owners start fresh identity; old responses/errors/timers/finalizers cannot change replacement/cache/badges/media/mirrors.
|
||||
|
||||
Preserve shared/cross-controller and ownerless legacy consumers, live success/error/bad-body/timeout, existing helper calls without lifecycle, native-HLS preference and existing media/HEAD/FIFO behavior. No existing assertion changed;35 new cases tighten uncovered HLS ownership behavior. This is a scope extension, not retroactive qualification by prior HEAD/media fixes.
|
||||
|
||||
Only controller HLS lifecycle argument and manifest-probe optional cancellation plumbing change. HLS engine/player/session/config, app, torrent and RangeGET stay outside this slice. No real playback/network-byte/browser/CPU/wholeStage3 qualification from unit evidence.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Hosted URL health Range fallback audit — 2026-10-23
|
||||
|
||||
Inspected clean storage-cors HEAD 265ce4261f7c42f653ce0ac6a49da0eea16f0f66. Source and retained tests show the public playback promise for a bounded Range GET fallback is not implemented in the hosted health probe.
|
||||
|
||||
The card-health path reaches UrlHealthController.handleUrlHealthBadge via feedCoordinator and probes candidate URLs in probeUrlList; playback calls the same controller through playbackCoordinator.probeUrl (js/app.js:5302-5305, js/app/playbackCoordinator.js:384-401,685, js/app/feedCoordinator.js:1319-1323, js/ui/urlHealthController.js:471-503). The controller sends fetch(trimmed, { method: "HEAD", mode: "no-cors", cache: "no-store", signal }) (urlHealthController.js:592-603). On network error, timeout, opaque response, or non-OK status such as 405, confirmPlayable falls back to a timed <video> load (urlHealthController.js:516-535,623-673; media queue and timeout at :321-460). That is a real media-element fallback, but the probe does not explicitly request a Range, read a bounded response prefix, or cancel a response-body reader at a byte cap.
|
||||
|
||||
tests/url-health-head-lifecycle.test.mjs:378-405 controls HEAD outcomes for success, error, timeout, opaque and 405. Its fake fetch records the HEAD request; its JSDOM media adapter signals simulated load/error. The separate 405 mirror case at :493-505 asserts the media fallback and then a mirror HEAD. These tests preserve fallback and lifecycle behavior, but do not assert Range headers, status/Content-Range, bytes read, body cancellation, or a bounded transfer. No Range, bytes=, or Range-GET contract appears in these three URL health suites. docs/playbackService-overview.md:21-26 describes a HEAD reachability probe and playback/torrent fallback. The campaign acceptance at docs/agents/CAMPAIGN-2026-10-22.md:9 explicitly calls for a bounded first chunk/cancel and a HEAD-incompatible host fixture.
|
||||
|
||||
Gap: current media-element fallback does not establish the promised controlled byte cap. This source/test audit does not prove the browser or origin never uses HTTP Range internally; the JSDOM transport does not observe those media bytes. No live host/network test was run. Health was tight (89% swap used at 2026-10-23T12:51Z), so no optional scratch reproduction was attempted.
|
||||
|
||||
Possible implementation/test owner if routed: client-reliability owns js/ui/urlHealthController.js; keep js/app.js and playback coordinator read-only. Add a dedicated controlled-transport regression with an explicitly selected HEAD-incompatible response and assert bounded Range request, first-chunk byte limit, body cancellation, and live HEAD/media/mirror controls. The exact HEAD incompatibility policy and byte cap need contract routing before implementation. No source or retained test changes were authorized by this audit.
|
||||
|
||||
+41
-14
@@ -378,16 +378,33 @@ export async function attachHlsSource(
|
||||
* native playback needs no CORS, so the caller should keep using the element
|
||||
* probe there.
|
||||
*
|
||||
* @returns {Promise<"ok"|"error"|"timeout">}
|
||||
* Optional card-probe lifecycle owns cancellation through both fetch and body
|
||||
* reading. Callers without a lifecycle retain the standalone probe behavior.
|
||||
* @returns {Promise<"ok"|"error"|"timeout"|"cancelled">}
|
||||
*/
|
||||
export async function probeHlsManifest(url, timeoutMs = 4000) {
|
||||
export async function probeHlsManifest(url, timeoutMs = 4000, lifecycle) {
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed || typeof fetch !== "function") {
|
||||
return "error";
|
||||
}
|
||||
if (lifecycle && (lifecycle.cancelled || !lifecycle.owners.size)) {
|
||||
lifecycle.cancelled = true;
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
const controller =
|
||||
typeof AbortController === "function" ? new AbortController() : null;
|
||||
let resolveCancellation;
|
||||
const cancellation = new Promise((resolve) => {
|
||||
resolveCancellation = resolve;
|
||||
});
|
||||
const cancelActive = () => {
|
||||
// Invalidate synchronously so a same-turn owner starts a fresh probe.
|
||||
lifecycle.cancelled = true;
|
||||
resolveCancellation("cancelled");
|
||||
controller?.abort();
|
||||
};
|
||||
if (lifecycle) lifecycle.cancelActive = cancelActive;
|
||||
let timedOut = false;
|
||||
const timeoutId =
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
@@ -397,22 +414,32 @@ export async function probeHlsManifest(url, timeoutMs = 4000) {
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const response = await fetch(trimmed, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal: controller?.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return "error";
|
||||
const request = async () => {
|
||||
try {
|
||||
const response = await fetch(trimmed, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal: controller?.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
return "error";
|
||||
}
|
||||
const text = await response.text();
|
||||
return text.trimStart().startsWith("#EXTM3U") ? "ok" : "error";
|
||||
} catch (err) {
|
||||
return timedOut ? "timeout" : "error";
|
||||
}
|
||||
const text = await response.text();
|
||||
return text.trimStart().startsWith("#EXTM3U") ? "ok" : "error";
|
||||
} catch (err) {
|
||||
return timedOut ? "timeout" : "error";
|
||||
};
|
||||
try {
|
||||
// Race the whole request, including response.text(): a transport or body
|
||||
// reader may ignore abort, but obsolete card work must still settle.
|
||||
return await Promise.race([request(), cancellation]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (lifecycle?.cancelActive === cancelActive) {
|
||||
lifecycle.cancelActive = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ export default class UrlHealthController {
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: defaultTimeout;
|
||||
const outcome = await probeHlsManifest(trimmed, effective);
|
||||
const outcome = await probeHlsManifest(trimmed, effective, lifecycle);
|
||||
return { outcome };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# HLS health lifecycle integrity
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: [new_tests, spec_correction]
|
||||
scenarios:
|
||||
- id: SCN-hls-owner-cancel
|
||||
given: "Actual observer/controller/helper with pending manifest fetch or body read"
|
||||
when: "Final owner disposes, including duplicate disposal and ignored/missing abort"
|
||||
then: "Prompt null, aborted when supported, timer cleanup once, no obsolete cache or mirror or badge"
|
||||
- id: SCN-hls-replacement
|
||||
given: "Cancelled HLS work and new same-source observer in same turn or later"
|
||||
when: "Old success, error or timer fires"
|
||||
then: "Fresh request/inflight survives; only current owner receives healthy cache and badge"
|
||||
- id: SCN-hls-shared-legacy
|
||||
given: "Two owners, cross-controller owners or ownerless legacy consumer in fetch/body phase"
|
||||
when: "One registered owner disposes"
|
||||
then: "One live GET, no abort, live healthy cache/badge and no disposed badge write"
|
||||
- id: SCN-hls-live-contract
|
||||
given: "Live HLS card with controlled response or timeout"
|
||||
when: "Good manifest, HTTP error, invalid body or timeout completes"
|
||||
then: "Existing healthy/offline/timeout and cleanup semantics preserved"
|
||||
- id: SCN-hls-boundary
|
||||
given: "Cancellation at HEAD handoff, native-support check or helper prestart"
|
||||
when: "Ownership disappears"
|
||||
then: "No manifest starts; native preference retains existing cancellable media behavior"
|
||||
- id: SCN-hls-hook-cleanup
|
||||
given: "Helper hook active or superseded by successor"
|
||||
when: "Cancellation or completion finalizes"
|
||||
then: "Own timer/hook removed once; successor hook preserved"
|
||||
observable_outcomes:
|
||||
- "Fetch count, abort signal, promise settlement, timers, cache, inflight identity and visible badges"
|
||||
- "No obsolete mirror/media start; active media slots and queue return to zero"
|
||||
determinism_controls:
|
||||
- "Real observer/controller/cache/helper with JSDOM; controlled external fetch/body/viewport/timers"
|
||||
- "Microtask/event-loop continuation drain only, no arbitrary sleeps, retries, force exits or public network"
|
||||
anti_cheat_rationale:
|
||||
prevents:
|
||||
- "Abort signal without prompt settlement"
|
||||
- "Fetch-only cancellation that leaves body reader pending"
|
||||
- "Same-source fresh observer inheriting obsolete work"
|
||||
- "Cancelling shared or legacy live owners"
|
||||
- "Late cache or badge writes and replacement deletion"
|
||||
- "Unconditional healthy response and lost live timeout/native branch"
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
Spec extension documented in docs/spec_changes/2026-10-22-client-reliability-9-active-hls.md. No existing tests/expectations changed. Original actual12mode baseline and initial fixture corrections are retained in20-54 investigation note; new35case suite against original isolated modules produces13pass/22fail/cancelled0.
|
||||
|
||||
Grouped mutant coverage: six final-owner cases reject hook/abort/prompt/timer/cache failures; twelve replacement cases reject sync invalidation and stale cache finalizer; six shared/cross/legacy cases reject lost shared owner; four live controls reject corrupted success/status/body/timeout; handoff/native/prestart cases reject missing handoff/guard/native branch; hook cases reject absent cleanup and deleting successor. Each group has failing mutants; all35 cases covered without changing expectations.
|
||||
|
||||
Final19 isolated variants below naturally exit1 with assertion failures and0cancelled. Source copies live in/tmp; actual worktree modules never mutated. Initial timer mutant accidentally targeted engine cleanup, survived, corrected to manifest-helper suffix; final result rejects timer retention. Baseline variant includes absent API TypeErrors in direct-hook cases as well as22 total failures; primary lifecycle cases fail assertions.
|
||||
|
||||
- baseline: exit1, fail22, cancelled0
|
||||
- no-handoff: exit1, fail19, cancelled0
|
||||
- no-helper-hook: exit1, fail19, cancelled0
|
||||
- no-cancel-abort: exit1, fail5, cancelled0
|
||||
- no-prompt-settlement: exit1, fail18, cancelled0
|
||||
- no-sync-invalidation: exit1, fail7, cancelled0
|
||||
- retained-timer: exit1, fail30, cancelled0
|
||||
- no-hook-cleanup: exit1, fail1, cancelled0
|
||||
- successor-hook-deleted: exit1, fail1, cancelled0
|
||||
- body-hook-lost: exit1, fail10, cancelled0
|
||||
- no-prestart-guard: exit1, fail3, cancelled0
|
||||
- cache-on-cancel: exit1, fail21, cancelled0
|
||||
- lost-shared-owner: exit1, fail6, cancelled0
|
||||
- native-branch-lost: exit1, fail2, cancelled0
|
||||
- healthy-cache-corrupted: exit1, fail19, cancelled0
|
||||
- bad-status-healthy: exit1, fail1, cancelled0
|
||||
- bad-body-healthy: exit1, fail1, cancelled0
|
||||
- no-timeout-abort: exit1, fail1, cancelled0
|
||||
- stale-inflight-finalizer: exit1, fail6, cancelled0
|
||||
@@ -0,0 +1,9 @@
|
||||
# HLS-only cancellation candidate readiness
|
||||
|
||||
- Parent HEADfb0e671d4da534a52d834cb761c285b862d86889 contains docs-only investigation over runtime d04d501e. Dispatch20:55:13Z explicitly routed helper/controller changes after independently reproducing12cases7pass/5fail/cancelled0/naturalexit1. Original and fixture-correction provenance in20-54 investigation note.
|
||||
- Product delta: controller one argument; helper optional lifecycle, cancellation hook and raced fetch+response.text, synchronous invalidation, transport abort where available, timer and identity-safe hook cleanup. Existing standalone caller and engine/player/native selection unchanged. Prettier formatting outside helper was restored to avoid engine edits. No existing tests modified.
|
||||
- New unique tests/url-health-hls-lifecycle.test.mjs35scenarios; original isolated modules yield13pass/22fail/cancelled0 (baseline mutant). Fixed original12case diagnostic12pass/0fail/0cancelled/naturalexit0. No baseline failure erased.
|
||||
- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node --test tests/url-health-hls-lifecycle.test.mjs tests/url-health-head-lifecycle.test.mjs tests/url-health-lifecycle.test.mjs tests/url-health-multisource.test.mjs tests/ui/url-health-controller.test.mjs tests/services/hlsPlayback.test.mjs > /tmp/client9-hls-focused-final.tap 2>&1`: approvedhost naturalexit0;6files/129pass/0fail/0cancelled/1042.269662ms. New35+existing94. Initial equivalent candidate with redundant controller preguard also129pass; removed redundant guard then verified final again.
|
||||
- `python3 /tmp/client9-hls-mutants.py > /tmp/client9-hls-mutants-final.log 2>&1`: runnerexit0;19isolated module/cache variants each naturalexit1/fail>0/cancelled0. Results/tmp/client9-hls-mutants.json. Initial retainedtimer variant hit engineclearTimeout and survived; corrected exacthelper suffix, rejected30scenarios, reran final19 on final candidate. No worktree source mutated for experiments.
|
||||
- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD npm run lint > /tmp/client9-hls-lint-final.log 2>&1`: approvedhost naturalexit0/all11checks. Earlier candidate lint alsoexit0. `git diff --check` exit0. No lint baseline/config change.
|
||||
- Full/normal gate, build/artifact and browser HLS lifecycle UNRUN for this follow-on; no product submission. Storage4 owns heavy slot. Await serialized slot after commit/currentunstable merge. No decoder/realplayback/network-byte/CPU/livepeer/realcloud/signer/production/wholeStage3 proof; torrent/RangeGET remain open.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Client-reliability-9 HLS final gate and build evidence
|
||||
|
||||
- Candidate: `280f043a6662e3a488fca3fea272ef99bc3a2728`, safely fast-forwarded to latest `unstable` `103eb04cd9a6aac559365b7bfb077efec99c8365`; HLS implementation/test inputs matched the landed candidate exactly.
|
||||
- Normal full gate: `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node tools/fleet/bin/landq.mjs submit`; final attempt completed with gate exit 0. Runner discovered 437 unique test files and started all 437 exactly once. Final file `tests/zap-totals.test.mjs` passed 13/13. Durable landq status: landed as `83fd2495b480bfafcc6aefd3a6bdd700b577d62e` at 2026-10-23 12:56:58.857 UTC. Candidate and merged SHA both verified ancestors of `unstable`.
|
||||
- Earlier attempts retained for accurate history: `d7184f07bd08cdfd657c83f1795579c5b2b2cddb` completed 437 starts but publication was rejected because the branches had diverged. The first full `test_unit.log` was overwritten by the later run using the same shared artifact path; its durable result record and contemporaneously observed 437-start count remain, but its complete log is unavailable.
|
||||
- Replacement attempt at the same candidate was interrupted at 398/437 starts after sustained heat readings of 88.8°C and 85.1°C. Only the verified gate test process group received SIGINT; landq recorded exit 1. This is an interrupted validation, not a product assertion failure. The partial log and result were copied to the fleet evidence directory before the successful final attempt.
|
||||
- Preserved artifacts: `/home/user/.local/state/agent-fleet/bitvid/evidence/client-reliability-9/` contains both prior result records, the 398-start partial log, the successful 437-start full log, the final result, hashes, and the documented first-log limitation.
|
||||
- Focused HLS validation before full gate: 6 files, 129 passed, 0 failed, 0 cancelled; 19 isolated mutants each failed naturally; lint passed all 11 checks. See `TEST_LOG_2026-10-22_21-03-00_client-reliability-9-hls-ready.md` and `TEST_INTEGRITY_2026-10-22_client-reliability-9-hls.md`.
|
||||
- Merged-tree build/artifact: `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD npm run build` exited 0. Build completed with version `c95abe22`; `verify:dist:deploy-artifact` passed for `dist/index.html`. The service-worker compatibility check reported that it skipped because it could not determine `HEAD^` / a commit delta in the build environment.
|
||||
- No HLS browser/native smoke, live media transport, peer, production, or torrent validation was performed. Playback QA received the next browser slot. Keep client-reliability-9 open for its separately routed torrent slice; this note records HLS completion only.
|
||||
@@ -0,0 +1,559 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { probeHlsManifest } from "../js/services/hlsPlayback.js";
|
||||
// Resolve real cache dependencies with installed Node packages before DOM setup.
|
||||
import * as cache from "../js/state/cache.js";
|
||||
import UrlHealthController from "../js/ui/urlHealthController.js";
|
||||
import { attachUrlHealthBadges } from "../js/urlHealthObserver.js";
|
||||
import {
|
||||
setCardLivenessPolicy,
|
||||
getCardLivenessPolicy
|
||||
} from "../js/constants.js";
|
||||
import { updateVideoCardSourceVisibility } from "../js/utils/cardSourceVisibility.js";
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
let sequence = 0;
|
||||
function fixture(t, { ignoreAbort = false, missingAbort = false } = {}) {
|
||||
const dom = new JSDOM("<!doctype html><body></body>", {
|
||||
url: "https://example.invalid"
|
||||
});
|
||||
const names = [
|
||||
"window",
|
||||
"document",
|
||||
"HTMLElement",
|
||||
"localStorage",
|
||||
"fetch",
|
||||
"IntersectionObserver",
|
||||
"requestAnimationFrame",
|
||||
"setTimeout",
|
||||
"clearTimeout",
|
||||
"AbortController"
|
||||
];
|
||||
const saved = names.map((key) => [
|
||||
key,
|
||||
Object.getOwnPropertyDescriptor(globalThis, key)
|
||||
]);
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
localStorage: dom.window.localStorage
|
||||
});
|
||||
if (missingAbort) globalThis.AbortController = undefined;
|
||||
const policy = getCardLivenessPolicy();
|
||||
setCardLivenessPolicy("show-pending");
|
||||
const resources = new Set(),
|
||||
listeners = new Map(),
|
||||
handlers = new Map(),
|
||||
pauses = [];
|
||||
const observers = [],
|
||||
media = [],
|
||||
heads = [],
|
||||
promises = [],
|
||||
warnings = [],
|
||||
registrations = [],
|
||||
visibility = [],
|
||||
checks = [];
|
||||
const id = (name) => `url-lifecycle-${++sequence}-${name}`;
|
||||
const url = (name) => `https://media.invalid/${name}.m3u8`;
|
||||
globalThis.IntersectionObserver = class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.cards = [];
|
||||
observers.push(this);
|
||||
}
|
||||
observe(card) {
|
||||
this.cards.push(card);
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
disconnect() {
|
||||
this.disconnected = true;
|
||||
}
|
||||
show() {
|
||||
this.callback(
|
||||
this.cards.map((target) => ({
|
||||
target,
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 1
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
const bodies = [],
|
||||
clears = [];
|
||||
const timers = new Map(),
|
||||
nativeSetTimeout = globalThis.setTimeout,
|
||||
nativeClearTimeout = globalThis.clearTimeout;
|
||||
globalThis.setTimeout = (fn, ms) => {
|
||||
if (!ms) return nativeSetTimeout(fn, ms);
|
||||
const handle = {};
|
||||
timers.set(handle, { fn, ms });
|
||||
return handle;
|
||||
};
|
||||
globalThis.clearTimeout = (handle) => {
|
||||
clears.push(handle);
|
||||
if (!timers.delete(handle)) nativeClearTimeout(handle);
|
||||
};
|
||||
globalThis.fetch = (source, options) =>
|
||||
options.method === "HEAD"
|
||||
? Promise.resolve({ ok: true, status: 200, type: "basic" })
|
||||
: new Promise((resolve, reject) => {
|
||||
const head = { source, options, resolve, reject, aborted: 0 };
|
||||
heads.push(head);
|
||||
options.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
head.aborted++;
|
||||
if (!ignoreAbort)
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
const proto = dom.window.HTMLMediaElement.prototype;
|
||||
const addListener = proto.addEventListener,
|
||||
removeListener = proto.removeEventListener;
|
||||
proto.addEventListener = function (type, fn, options) {
|
||||
if (["loadeddata", "canplay", "error"].includes(type)) {
|
||||
if (!listeners.has(this)) listeners.set(this, new Map());
|
||||
if (!handlers.has(this)) handlers.set(this, new Map());
|
||||
listeners.get(this).set(type, fn);
|
||||
handlers.get(this).set(type, fn);
|
||||
}
|
||||
return addListener.call(this, type, fn, options);
|
||||
};
|
||||
proto.removeEventListener = function (type, fn, options) {
|
||||
listeners.get(this)?.delete(type);
|
||||
return removeListener.call(this, type, fn, options);
|
||||
};
|
||||
proto.load = function () {
|
||||
if (this.getAttribute("src")) {
|
||||
if (!media.includes(this)) media.push(this);
|
||||
resources.add(this);
|
||||
} else resources.delete(this);
|
||||
};
|
||||
proto.pause = function () {
|
||||
pauses.push(this);
|
||||
};
|
||||
const controller = new UrlHealthController({
|
||||
state: {
|
||||
...cache,
|
||||
setInFlightUrlProbe(...args) {
|
||||
promises.push(args[2]);
|
||||
return cache.setInFlightUrlProbe(...args);
|
||||
}
|
||||
},
|
||||
logger: {
|
||||
warn(...args) {
|
||||
warnings.push(args);
|
||||
}
|
||||
},
|
||||
utils: {
|
||||
updateVideoCardSourceVisibility(card) {
|
||||
visibility.push(card);
|
||||
updateVideoCardSourceVisibility(card);
|
||||
}
|
||||
}
|
||||
});
|
||||
function feed(
|
||||
videos,
|
||||
{
|
||||
container = document.createElement("section"),
|
||||
consumer = controller
|
||||
} = {}
|
||||
) {
|
||||
container.replaceChildren();
|
||||
for (const video of videos) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "card";
|
||||
card.dataset.videoId = video.id;
|
||||
card.dataset.urlHealthEventId = video.id;
|
||||
card.dataset.urlHealthUrl = encodeURIComponent(video.url);
|
||||
card.dataset.urlHealthState = "checking";
|
||||
card.dataset.streamHealthState = "unsupported";
|
||||
const badge = document.createElement("span");
|
||||
badge.dataset.urlHealthState = "checking";
|
||||
card.append(badge);
|
||||
container.append(card);
|
||||
updateVideoCardSourceVisibility(card);
|
||||
}
|
||||
document.body.append(container);
|
||||
const registration = { container, videos };
|
||||
registration.dispose = attachUrlHealthBadges(
|
||||
container,
|
||||
({ badgeEl, url, eventId }) => {
|
||||
checks.push(eventId);
|
||||
consumer.handleUrlHealthBadge({
|
||||
video: videos.find((v) => v.id === eventId),
|
||||
url,
|
||||
badgeEl
|
||||
});
|
||||
}
|
||||
);
|
||||
registration.observer = observers.at(-1);
|
||||
registration.cards = [...container.children];
|
||||
registration.badges = registration.cards.map(
|
||||
(card) => card.firstElementChild
|
||||
);
|
||||
registrations.push(registration);
|
||||
return registration;
|
||||
}
|
||||
const video = (name) => {
|
||||
const key = id(name);
|
||||
return { id: key, url: url(key) };
|
||||
};
|
||||
const done = (element, event = "loadeddata") =>
|
||||
element.dispatchEvent(new dom.window.Event(event));
|
||||
async function finishAll() {
|
||||
// Event-driven drain: each iteration completes the currently created media.
|
||||
// No wall-clock delay or retry of a failed assertion.
|
||||
for (const head of heads)
|
||||
head.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
type: "basic",
|
||||
text: async () => "#EXTM3U\nsegment.ts"
|
||||
});
|
||||
for (const body of bodies) body.resolve("#EXTM3U\nsegment.ts");
|
||||
await flush();
|
||||
let handled = 0;
|
||||
while (handled < media.length) {
|
||||
for (const element of media.slice(handled)) {
|
||||
done(element);
|
||||
handled++;
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
await Promise.all(promises);
|
||||
await flush();
|
||||
}
|
||||
t.after(async () => {
|
||||
for (const registration of registrations) registration.dispose();
|
||||
await finishAll();
|
||||
dom.window.close();
|
||||
setCardLivenessPolicy(policy);
|
||||
for (const [key, descriptor] of saved) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
});
|
||||
return {
|
||||
dom,
|
||||
bodies,
|
||||
clears,
|
||||
resources,
|
||||
listeners,
|
||||
handlers,
|
||||
pauses,
|
||||
controller,
|
||||
media,
|
||||
heads,
|
||||
timers,
|
||||
promises,
|
||||
warnings,
|
||||
visibility,
|
||||
checks,
|
||||
feed,
|
||||
video,
|
||||
done,
|
||||
finishAll
|
||||
};
|
||||
}
|
||||
|
||||
// Actual observer/controller/cache/manifest helper; only IO, clock and viewport
|
||||
// boundaries are controlled. No engine/decoder/public network is involved.
|
||||
const inFlight = (v) =>
|
||||
cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true });
|
||||
const cached = (v) => cache.getCachedUrlHealth(v.id, v.url);
|
||||
const manifest = {
|
||||
ok: true,
|
||||
text: async () => "#EXTM3U\n#EXTINF:2\nsegment.ts"
|
||||
};
|
||||
function tracking(promise) {
|
||||
const result = { settled: false };
|
||||
promise.then((value) => Object.assign(result, { settled: true, value }));
|
||||
return result;
|
||||
}
|
||||
function bodyReader(f) {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
const body = { promise, resolve, reject };
|
||||
f.bodies.push(body);
|
||||
return body;
|
||||
}
|
||||
async function start(f, { phase = "fetch", mirror = false } = {}) {
|
||||
const v = f.video(phase);
|
||||
if (mirror) v.sources = [{ url: `${v.url}?mirror` }];
|
||||
const old = f.feed([v]);
|
||||
old.observer.show();
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 1);
|
||||
assert.equal(f.heads[0].options.method, "GET");
|
||||
assert.equal(f.media.length, 0);
|
||||
let body;
|
||||
if (phase === "body") {
|
||||
body = bodyReader(f);
|
||||
f.heads[0].resolve({ ok: true, text: () => body.promise });
|
||||
await flush();
|
||||
}
|
||||
return {
|
||||
v,
|
||||
old,
|
||||
body,
|
||||
promise: f.promises[0],
|
||||
result: tracking(f.promises[0])
|
||||
};
|
||||
}
|
||||
for (const phase of ["fetch", "body"]) {
|
||||
for (const transport of ["abortable", "ignores-abort", "missing-abort"]) {
|
||||
test(`HLS ${phase} final owner releases resources with ${transport}`, async (t) => {
|
||||
const f = fixture(t, {
|
||||
ignoreAbort: transport === "ignores-abort",
|
||||
missingAbort: transport === "missing-abort"
|
||||
});
|
||||
const { v, old, result } = await start(f, { phase, mirror: true });
|
||||
const timer = [...f.timers.keys()][0];
|
||||
old.dispose();
|
||||
old.dispose();
|
||||
await flush();
|
||||
assert.equal(
|
||||
result.settled,
|
||||
true,
|
||||
"settle independently of fetch/body cooperation"
|
||||
);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(f.heads[0].aborted, transport === "missing-abort" ? 0 : 1);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.clears.filter((x) => x === timer).length, 1);
|
||||
assert.equal(cached(v), null);
|
||||
assert.equal(inFlight(v), null);
|
||||
assert.equal(f.heads.length, 1, "no obsolete mirror");
|
||||
assert.equal(f.media.length, 0);
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
assert.equal(f.controller.activeVideoProbes, 0);
|
||||
assert.equal(f.controller.videoProbeQueue.length, 0);
|
||||
await f.finishAll();
|
||||
assert.equal(cached(v), null, "late completion cannot cache");
|
||||
assert.equal(f.clears.filter((x) => x === timer).length, 1);
|
||||
});
|
||||
}
|
||||
for (const timing of ["same-turn", "after-flush"])
|
||||
for (const late of ["success", "error", "timer"]) {
|
||||
test(`HLS ${phase} ${timing} replacement survives old ${late}`, async (t) => {
|
||||
const f = fixture(t, { ignoreAbort: true });
|
||||
const { v, old, body, promise, result } = await start(f, { phase });
|
||||
const staleTimer = [...f.timers.values()][0].fn;
|
||||
old.dispose();
|
||||
if (timing === "after-flush") await flush();
|
||||
const live = f.feed([v]);
|
||||
live.observer.show();
|
||||
const replacement = inFlight(v);
|
||||
assert.notEqual(
|
||||
replacement,
|
||||
promise,
|
||||
"new owner requires fresh inflight identity"
|
||||
);
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 2);
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(inFlight(v), replacement);
|
||||
assert.equal(f.timers.size, 1);
|
||||
if (late === "timer") staleTimer();
|
||||
else if (phase === "body") {
|
||||
if (late === "success") body.resolve("#EXTM3U\nold.ts");
|
||||
else body.reject(Error("late body failure"));
|
||||
} else if (late === "success") f.heads[0].resolve(manifest);
|
||||
else f.heads[0].reject(Error("late fetch failure"));
|
||||
await flush();
|
||||
assert.equal(inFlight(v), replacement);
|
||||
assert.equal(cached(v), null);
|
||||
assert.equal(f.timers.size, 1);
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
f.heads[1].resolve(manifest);
|
||||
await replacement;
|
||||
await flush();
|
||||
assert.equal(cached(v).status, "healthy");
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
assert.equal(inFlight(v), null);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
}
|
||||
for (const kind of ["shared", "cross-controller", "legacy"]) {
|
||||
test(`HLS ${phase} cancellation preserves ${kind} survivor`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const { v, old, result } = await start(f, { phase });
|
||||
const consumer =
|
||||
kind === "cross-controller"
|
||||
? new UrlHealthController({ state: cache, logger: { warn() {} } })
|
||||
: f.controller;
|
||||
let live;
|
||||
if (kind === "legacy") {
|
||||
const badge = document.createElement("span");
|
||||
document.body.append(badge);
|
||||
consumer.handleUrlHealthBadge({ video: v, url: v.url, badgeEl: badge });
|
||||
live = { badges: [badge] };
|
||||
} else {
|
||||
live = f.feed([v], { consumer });
|
||||
live.observer.show();
|
||||
}
|
||||
await flush();
|
||||
old.dispose();
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 1);
|
||||
assert.equal(f.heads[0].aborted, 0);
|
||||
assert.equal(result.settled, false);
|
||||
assert.equal(f.timers.size, 1);
|
||||
await f.finishAll();
|
||||
assert.equal(result.value.status, "healthy");
|
||||
assert.equal(cached(v).status, "healthy");
|
||||
assert.equal(live.badges[0].dataset.urlHealthState, "healthy");
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, "checking");
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const outcome of ["success", "error", "bad-body", "timeout"]) {
|
||||
test(`HLS live ${outcome} preserves health/cache semantics`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const { v, old, result } = await start(f);
|
||||
if (outcome === "timeout") [...f.timers.values()][0].fn();
|
||||
else
|
||||
f.heads[0].resolve(
|
||||
outcome === "success"
|
||||
? manifest
|
||||
: outcome === "error"
|
||||
? { ok: false, status: 500 }
|
||||
: { ok: true, text: async () => "<html>not a manifest</html>" }
|
||||
);
|
||||
await flush();
|
||||
assert.equal(result.settled, true);
|
||||
const status =
|
||||
outcome === "success"
|
||||
? "healthy"
|
||||
: outcome === "timeout"
|
||||
? "timeout"
|
||||
: "offline";
|
||||
assert.equal(result.value.status, status);
|
||||
assert.equal(cached(v).status, status);
|
||||
assert.equal(old.badges[0].dataset.urlHealthState, status);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.heads[0].aborted, outcome === "timeout" ? 1 : 0);
|
||||
});
|
||||
}
|
||||
test("HLS disposal during HEAD handoff prevents manifest GET", async (t) => {
|
||||
const f = fixture(t);
|
||||
const v = f.video("handoff"),
|
||||
old = f.feed([v]);
|
||||
old.observer.show();
|
||||
old.dispose();
|
||||
await flush();
|
||||
const result = tracking(f.promises[0]);
|
||||
await flush();
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(f.heads.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(cached(v), null);
|
||||
});
|
||||
test("HLS native preference keeps cancellable media path", async (t) => {
|
||||
const f = fixture(t);
|
||||
f.dom.window.HTMLMediaElement.prototype.canPlayType = () => "probably";
|
||||
const v = f.video("native"),
|
||||
old = f.feed([v]);
|
||||
old.observer.show();
|
||||
await flush();
|
||||
assert.equal(f.heads.length, 0);
|
||||
assert.equal(f.media.length, 1);
|
||||
assert.equal(f.controller.activeVideoProbes, 1);
|
||||
old.dispose();
|
||||
await flush();
|
||||
const result = tracking(f.promises[0]);
|
||||
await flush();
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(f.controller.activeVideoProbes, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.resources.size, 0);
|
||||
assert.equal(cached(v), null);
|
||||
});
|
||||
for (const preCancelled of [false, true])
|
||||
test(`manifest helper skips cancelled lifecycle before start ${preCancelled}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
const lifecycle = {
|
||||
owners: new Set(preCancelled ? [{}] : []),
|
||||
cancelled: preCancelled
|
||||
};
|
||||
const result = tracking(
|
||||
probeHlsManifest(f.video("prestart").url, 5000, lifecycle)
|
||||
);
|
||||
await flush();
|
||||
assert.equal(
|
||||
result.settled,
|
||||
true,
|
||||
"pre-cancelled helper settles without IO"
|
||||
);
|
||||
assert.equal(result.value, "cancelled");
|
||||
assert.equal(lifecycle.cancelled, true);
|
||||
assert.equal(f.heads.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
test("manifest helper clears its hook exactly once on cancellation", async (t) => {
|
||||
const f = fixture(t, { ignoreAbort: true });
|
||||
const lifecycle = { owners: new Set([{}]) };
|
||||
const promise = probeHlsManifest(f.video("hook").url, 5000, lifecycle);
|
||||
const cancel = lifecycle.cancelActive;
|
||||
const timer = [...f.timers.keys()][0];
|
||||
cancel();
|
||||
cancel();
|
||||
assert.equal(lifecycle.cancelled, true);
|
||||
const result = tracking(promise);
|
||||
await flush();
|
||||
assert.equal(
|
||||
result.settled,
|
||||
true,
|
||||
"cancelled helper settles without cooperative IO"
|
||||
);
|
||||
assert.equal(result.value, "cancelled");
|
||||
assert.equal(lifecycle.cancelActive, null);
|
||||
assert.equal(f.heads[0].aborted, 1);
|
||||
assert.equal(f.clears.filter((x) => x === timer).length, 1);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
test("manifest helper finalizer preserves a successor cancellation hook", async (t) => {
|
||||
const f = fixture(t);
|
||||
const lifecycle = { owners: new Set([{}]) };
|
||||
const promise = probeHlsManifest(f.video("successor").url, 5000, lifecycle);
|
||||
const successor = () => {};
|
||||
lifecycle.cancelActive = successor;
|
||||
f.heads[0].resolve(manifest);
|
||||
assert.equal(await promise, "ok");
|
||||
assert.equal(lifecycle.cancelActive, successor);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test("HLS cancellation during native-support check prevents manifest start", async (t) => {
|
||||
const f = fixture(t);
|
||||
const v = f.video("support-handoff"),
|
||||
old = f.feed([v]);
|
||||
f.dom.window.HTMLMediaElement.prototype.canPlayType = () => {
|
||||
old.dispose();
|
||||
return "";
|
||||
};
|
||||
old.observer.show();
|
||||
await flush();
|
||||
const result = tracking(f.promises[0]);
|
||||
await flush();
|
||||
assert.equal(result.settled, true);
|
||||
assert.equal(result.value, null);
|
||||
assert.equal(f.heads.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(cached(v), null);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
- [x] Preserve original actual12mode baseline5fail/7pass and fixture corrections.
|
||||
- [x] Minimal optional HLS cancellation plumbing and35 new lifecycle/helper scenarios.
|
||||
- [x] Focused129/6files and19 failing isolated mutants,cancelled0.
|
||||
- [ ] Final lint11,commit/merge currentunstable and READY/resource request.
|
||||
- [ ] Allocated full/normal landing/build/nativebrowser; no wider HLSplayback/torrent/RangeGET/Stage3 claim.
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
// Bitvid policy: cron may work only while an interactive fleet is alive and unpaused.
|
||||
// Kept separate from sched.sh so kit updates cannot restore ignorePause behavior.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig, pidAlive, killProcessGroup, runProcessGroup } from './lib.mjs';
|
||||
|
||||
export function inactiveReason(c) {
|
||||
if (fs.existsSync(path.join(c.stateDir, 'PAUSED'))) return 'fleet paused';
|
||||
for (const area of c.areas) {
|
||||
try {
|
||||
const run = JSON.parse(fs.readFileSync(path.join(c.stateDir, 'runs', `${area.id}.json`), 'utf8'));
|
||||
if (run.status === 'running' && Number.isInteger(run.pid) && run.pid > 1 && pidAlive(run.pid)) return null;
|
||||
} catch { /* Missing or invalid state is not permission to launch. */ }
|
||||
}
|
||||
return 'fleet down';
|
||||
}
|
||||
|
||||
export async function guardSchedule(c, job, { scheduler = path.join(path.dirname(fileURLToPath(import.meta.url)), 'sched.sh'), pollMs = 1000 } = {}) {
|
||||
if (!c.schedules.some((entry) => entry.name === job)) throw new Error(`Unknown scheduled job: ${job}`);
|
||||
const reason = inactiveReason(c);
|
||||
if (reason) return { status: 0, reason };
|
||||
let poll;
|
||||
let force;
|
||||
let stopped;
|
||||
const result = await runProcessGroup({
|
||||
cmd: '/bin/bash', args: [scheduler, job], cwd: c.repo, env: process.env,
|
||||
onSpawn(pid) {
|
||||
poll = setInterval(() => {
|
||||
const why = inactiveReason(c);
|
||||
if (!why || stopped) return;
|
||||
stopped = why;
|
||||
// Only the process group this invocation created. Nested fleet runners
|
||||
// receive TERM and forward it to their own agent process groups.
|
||||
killProcessGroup(pid, 'SIGTERM');
|
||||
force = setTimeout(() => killProcessGroup(pid, 'SIGKILL'), 3000);
|
||||
}, pollMs);
|
||||
},
|
||||
}).finally(() => {
|
||||
clearInterval(poll);
|
||||
clearTimeout(force);
|
||||
});
|
||||
return { status: stopped ? 0 : (result.status ?? 1), reason: stopped ?? null };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
const c = loadConfig();
|
||||
const result = await guardSchedule(c, process.argv[2]);
|
||||
process.exitCode = result.status;
|
||||
} catch (error) {
|
||||
console.error(`Bitvid schedule guard: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { guardSchedule } from '../bin/bitvid-schedule-guard.mjs';
|
||||
|
||||
const moduleUrl = new URL('../bin/bitvid-schedule-guard.mjs', import.meta.url).href;
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bitvid-schedule-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const c = { repo: root, stateDir: root, areas: [{ id: 'dispatch' }], schedules: [{ name: 'resources' }, { name: 'restart' }] };
|
||||
fs.mkdirSync(path.join(root, 'runs'));
|
||||
const record = path.join(root, 'runs', 'dispatch.json');
|
||||
const scheduler = path.join(root, 'schedule.sh');
|
||||
fs.writeFileSync(scheduler, 'echo started\nprintf yes > "'+root+'/launched"\n');
|
||||
const live = () => fs.writeFileSync(record, JSON.stringify({ status: 'running', pid: process.pid }));
|
||||
return { c, root, record, scheduler, live };
|
||||
}
|
||||
|
||||
for (const job of ['resources', 'restart']) {
|
||||
test(`${job} does not launch while down`, async (t) => {
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(await guardSchedule(f.c, job, { scheduler: f.scheduler }), { status: 0, reason: 'fleet down' });
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'launched')), false);
|
||||
});
|
||||
test(`${job} cannot bypass pause even with a live fleet`, async (t) => {
|
||||
const f = fixture(t); f.live(); fs.writeFileSync(path.join(f.root, 'PAUSED'), 'owner pause');
|
||||
assert.deepEqual(await guardSchedule(f.c, job, { scheduler: f.scheduler }), { status: 0, reason: 'fleet paused' });
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'launched')), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('stopped record pointing at a live unrelated process cannot start a job', async (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(f.record, JSON.stringify({ status: 'stopped', pid: process.pid }));
|
||||
assert.equal((await guardSchedule(f.c, 'resources', { scheduler: f.scheduler })).reason, 'fleet down');
|
||||
});
|
||||
|
||||
test('live unpaused fleet launches a short job and exits naturally', async (t) => {
|
||||
const f = fixture(t); f.live();
|
||||
assert.deepEqual(await guardSchedule(f.c, 'resources', { scheduler: f.scheduler }), { status: 0, reason: null });
|
||||
assert.equal(fs.readFileSync(path.join(f.root, 'launched'), 'utf8'), 'yes');
|
||||
});
|
||||
|
||||
for (const transition of ['pause', 'down']) {
|
||||
test(`already-running scheduled child is stopped on ${transition}`, { timeout: 5000 }, async (t) => {
|
||||
const f = fixture(t); f.live();
|
||||
fs.writeFileSync(f.scheduler, 'echo started\nexec sleep 600\n');
|
||||
const code = `import {guardSchedule} from ${JSON.stringify(moduleUrl)}; const r=await guardSchedule(${JSON.stringify(f.c)},'resources',${JSON.stringify({scheduler:f.scheduler,pollMs:25})}); console.log(JSON.stringify(r)); process.exitCode=r.status;`;
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', code], { stdio: ['ignore','pipe','pipe'] });
|
||||
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
|
||||
let output = '';
|
||||
const exited = new Promise((resolve, reject) => { child.once('error',reject); child.once('close',(status)=>resolve(status)); });
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.stdout.on('data', chunk => { output += chunk; if (output.includes('started\n')) resolve(); });
|
||||
child.once('exit', () => { if (!output.includes('started\n')) reject(new Error('child exited before ready')); });
|
||||
});
|
||||
if (transition === 'pause') fs.writeFileSync(path.join(f.root,'PAUSED'),'pause');
|
||||
else fs.writeFileSync(f.record, JSON.stringify({status:'stopped',pid:process.pid}));
|
||||
assert.equal(await exited, 0);
|
||||
assert.match(output, transition === 'pause' ? /fleet paused/ : /fleet down/);
|
||||
assert.doesNotThrow(() => process.kill(process.pid, 0));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user