The reseller panel's api?action=… handlers lived in one 1300-line
Infrastructure class behind ResellerApiController. They are HTTP handlers,
so they now sit under Public/Controllers/Reseller/Ajax, one controller per
group (stats, lines, devices, users, streams, activation codes), and are
registered with $router->api() as the admin panel's are. Handler bodies
moved verbatim; BaseResellerAjaxController::begin() keeps the guards the
single endpoint applied to every action. An unknown action now answers
{"result":false} from ResellerApiController.
Tests reach the actions through routes/reseller.php and dispatchApi()
(tests/Support/ResellerAjax). The old file is dropped from the LB strip
list and recorded in deleted_files.txt.
Seven unit tests started redis-server on a random port in 20000-40000 (it
overlaps the kernel's ephemeral range, so it can be taken) and gave it
2.5 s to listen, then went on whether or not it did. On a busy runner
ConnectionIngestIdempotencyTest's redis cases failed with "Connection
refused" (PR #319's CI). ConnectionDivergenceTest already had the fix;
it now lives in tests/Support/RedisServer: a port the kernel picks free,
up to 10 s to listen, and a server that never listens is redis not
available, so its tests skip instead of erroring.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CWvQvZGYnikZ2bx17R6fN6
A run printed 28 cluster:exec refusals on stderr and the VOD importer's
progress on stdout, all from passing tests.
- ClusterExecCommand writes its refusals through fail(), to STDERR unless
useErrors() names another stream; the test bootstrap sends them to memory.
- The VodItemImporter tests buffer the importer's CLI progress.
- RedisConnectionCloseTest asserts the users cron's "cleanup postponed" line
instead of printing it.
The suite ran on in-memory SQLite, an engine production never uses: it hid
column lengths, MySQL-only syntax and type coercion (the identity bug fixed in
the previous commit passed CI that way). SQLite is gone:
- TestDb connects only to MariaDB/MySQL (XCVM_TEST_DB_DSN/_USER/_PASS; root over
the local socket, else 127.0.0.1:3306). Each instance gets a throwaway
schema, dropped with it, in the installer's sql_mode, with an explicit
connect timeout (a boot under test can zero default_socket_timeout).
- InstallSchema and the tests take the production DDL as is: the SQLite
reducers, the keys they re-added, the faked information_schema and the
sqliteCreateFunction() stand-ins are removed; test DDL is MySQL DDL.
- ReplicaBootTest, DbConnectRefusalTest and LazyDatabaseHandlerTest reach the
test server instead of SQLite files; RecordingFinalizer no longer swallows
an information_schema failure "for tests on SQLite".
- `make test-db` starts a throwaway mariadb:11.4 (tmpfs) for a machine without
a server; the CI PHPUnit job gets the same as a service.
The suite also runs on a panel host with the bundled PHP and its own php.ini:
tests reach the code through MAIN_HOME, not `<repo>/src`; repository checks
and process-isolated tests (ionCube Loader + OPcache CLI segfault on a script
read from stdin) are in the skip-on-panel group; xcvm_test_child_php() starts
a child that fakes \XC_VM without the real extension; Redis-down tests use
RedisManager::useConnector() instead of an eval'd \XC_VM.
Stale opt-in tests fixed on the way: the real-agent provisioning test answers
the agent-started check, the extension integration test sends the typed starts
and reads the binary signature raw, a fake producer starts as PHP_BINARY, and
the certbot test waits for the line its background command writes.
Movies/Series -> Import (M3U or folder) spawned `console.php watch_item`,
a command that moved into the optional watch module during the module
split. Without the module the import reported success and imported
nothing.
The worker now lives in core as Domain\Vod\VodItemImporter behind the
`vod_import_item` command, and both import paths call it. It no longer
writes the module-owned watch_logs table: each file's outcome is
dispatched as a VodImportResultEvent for the module to log.
It also dispatches StreamsChangedEvent after inserting or upgrading a
stream, so Watch Folder imports now reach load balancers (R2 version
stamp), which the module copy never did.
The watch module's tests for the worker move here, plus an end-to-end
run() test for an M3U episode import. lb_settings_keys is regenerated
for the two settings the importer reads.
Phase 9 (plan 9.8): where the node's xcvm_core offers it
(method_exists cluster_lease_state), NodeLease asks the extension first,
every 2 s like the agent's file. It hands the extension the agent's lease
(agent.json, exact signed bytes) when it is newer by iat than the one held,
and does not offer a refused one again. A `live` or `expired` answer
decides (verdict `source: extension`), judged on the extension's anchor on
MAIN's clock, which never moves back; `none`, an older extension or one that
throws falls back to the agent's file, which still serves on every doubt
(`source: agent`). On the node the same verdict makes license_valid() true,
so LicenseGate lets it use fanout.
Tests: LeaseVerdictCacheTest, TimeAnchorMonotoneTest and
MainClockRollbackTest (extension absent, a fake in its place), and an
end-to-end case in ClusterExtensionIntegrationTest against a real
test-hooks xcvm_core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rdv7hXd8RWhLHoj5Ma2ZqY
A module declares SourceDriverInterface classes in module.json
("source_drivers", optional "start_timeout") and claims URL schemes of its
own; core runs the driver's engine in ffmpeg's place for those sources.
- SourceDriverRegistry: scheme -> driver, loaded via ModuleLoader::loadAll()
(no bootAll, so the streaming entry point works), core schemes reserved,
a scheme claimed by two modules goes to neither, unknown schemes refused.
- StreamProcess: per-source choice in the supervised spec and the PHP
monitor path; ffmpeg-only settings (transcode, maps, RTMP, delay…) refuse
the driver; argv escaped by core, must name the playlist; no fallback_cmd
or probe_cmd; the driver's start_timeout extends the start wait.
- ProcessManager recognises a driver engine by binary + playlist, so
monitor, stop and cron treat it as the stream's producer.
- MonitorCommand priority/force checks go through sourceAnswers()
(driver's available() or ffprobe).
- Offset the added branches in startStream/buildSupervisorSpec/producerKind
with behaviour-preserving simplifications (CRAP ratchet).
A finished recording's VOD, which MAIN attaches to the node itself
(RecordingFinalizer::finish: pid 1, to_analyze 1), never reached the
node's store, so once the store answered cron:vod never analysed it and
the VOD relay refused it. The recorder now passes the VOD's id to
ContentSink::recordingDone(), which keeps that row's state without an
event. The Rescan VOD, Recreate channels and symlink tools' resets stay
MAIN-only writes the store does not follow (a known limit, recorded).
The lists that walk every stream the node holds read each stream's
cache entry and state twice per call; on a node with many movies the
on-demand daemon's 0.8 s pass took 0.7 s. The stream caches' index now
carries what they filter on (ReplicaStreamCache::meta(): on_demand,
type, live, direct_source, direct_proxy), the lists read an entry only
when the index does not rule it out, the analysis takes the store's
to_analyze first and finds a run's streams once: 0.04 s at 20,000.
The seed reads MAIN's rows outside the store's lock, so a slow MAIN
database holds up no writer, and copies them only if the store's new
generation (bumped by every kept write and every lapse) did not move,
nothing is pending or unsent, and they fit MAX_STREAMS. A write that
goes to MAIN's row alone (STREAMS off, or mode 0 and 1 without the
agent) lapses the store once it landed, under the lock; mode 0 and 1
mark nothing unsent, so resend() no longer replays what MAIN has. The
unsent marker is written before its entry and removed only after a
walk that left nothing unsent and that no write marked again, and
cron:streams resends before it asks for the store. A process whose seed
failed waits 30 s before trying again. In mode 2 without the agent a
movie's analysis is not written and the movie stays due.
The readers' parity fixture gains a row for each filter they apply and
a pass with an index lacking the new columns; the writer branches, the
recorded VOD, StreamProcess's store lookups and cron:cleanup's pruning,
partial section and cron:streams' resend are tested. The suite points
the store at a per-run directory.
ADR 0004's thirteenth increment, the plan's Phase 7 status and the
cluster seams page say so; the agent's wire contract is unchanged.
Root wrote the audits' files with its own rights in directories xc_vm can
write. cron:root_signals, `startup` and cluster:root count every connect
in mode 1, and the counts, the log, `since` and audit.json's temporary
file followed any link planted there, then were chowned (links followed
too). SettingsAudit::asAgentUser now runs that work as the owner of
config/cluster/: root switches its effective gid, groups and uid to that
user's and back after, so the kernel applies xc_vm's permissions and what
root makes is xc_vm's without a chown. Root writes nothing when
config/cluster/ is missing, a link or root's. The settings misses' merge
and publish use the same path.
A count rewrites its day file in place under an exclusive lock, but the
report read it with no lock and could see a day emptied, reporting SQL 0
for up to a minute. Day files are now read under a shared lock
(SettingsAudit::readDay), the misses' too.
cron:cleanup prunes both audits and rewrites audit.json before its first
query, which mode 2 refuses, so the audit stays bounded there.
`connects_since` now marks where the counts start: mode 0 deletes the
days it counted with it, and the report leaves out a day before its UTC
day.
ArchitectureTest's connect rule matched only `\XC_VM::` with a backslash
and one case: it now matches any case, with or without the backslash,
`new Redis;`, `PDO::connect(`, and forbids mysqli, outside comments and
string literals, with a self-test of the patterns.
Tests: the flush action run through executeAction() with iptables stubbed
and a database that refuses every other statement (a fall-through into
reboot fails); rootCommandsFromMain() with COMMANDS on and no trusted
pin; a new site rewriting a fresh audit.json; RedisCache skipped as
connect machinery; DatabaseFactory::open() keeping its handle; a
connects_since of 0 dropped; a report read while another process counts
never going down; links planted in both directories leaving root-only
files as they were. DbConnectRefusalTest runs on a fixed clock. A suite
run as root hands each test's tree to nobody (Tests\Support\AgentUser).
ADR 0004 and the plan: cron:root_signals in mode 2 runs its iptables sync
and keepalives before the refused signals loop when COMMANDS and the pin
are on; cron:cleanup's stream checks join the known limits; fifteen
direct sites, not sixteen; the seventh increment's refusal notes and
ReplicaBoot's docblock no longer say the refusal is to come. The agent's
contract is unchanged.
The connect audit behind the cutover gate (plan, section 10, step 1)
was a trace nothing switched on: NodeRole looked for an `enabled` file
no code wrote, and the log grew one unbounded line per connect.
NodeRole::auditConnects() now counts on a node in mode 1 or 2, by its
agent's flows.json alone and at each connect, so a mode switch counts
from the next one; the manual trace (XCVM_CONNECT_AUDIT=1, the
`enabled` file) stays. Database::db_connect() and RedisManager::connect()
call ConnectAudit::guard(), which counts the attempt with its caller.
ConnectAudit keeps, per UTC day, exact counts per kind and per site
(at most 32 sites, the rest under "*", each site at most 160 bytes of
printable ASCII) and a log of one line per connect up to 1 MiB; eight
days are kept, so the directory stays under 9 MiB. A `since` file
records when the node's audit began. The lazy handle and the factory
are skipped when finding the caller.
The last seven days travel in the audit.json the agent already sends as
the heartbeat's `audit`: `sql_connects`, `redis_connects`, `sites` and
`connects_since`, beside `settings_misses`. It is rewritten when a
connect adds a site to its day or the file is a minute old, and by
cron:cleanup every hour; mode 0 removes it and the `since` file. A root
process hands what it makes to the agent's user, as SettingsAudit does,
and a process that cannot read the days leaves the report as it is.
Plan section 9, R1 `settings`: API nodes log unknown-key reads to
`audit.settings_misses`. Once a node's replica owns its settings, a key
outside lb_settings_keys.php is absent there, and the CI scan behind the
allowlist cannot see a key built at run time. The count names such reads
before they fail.
On the node (Core/Cluster/SettingsAudit): in mode 1 or 2, as the agent's
flows.json says (NodeFlows::declared, no database and no servers read),
each read through SettingsManager's getters (get, getBool, getInt,
getString, getArray, has) of a key outside the allowlist's `keys` and
`withheld` is counted. A process keeps its counts in memory and merges
them at exit, and every minute while it runs, into a per-day file under
its lock (STORAGE_PATH/cluster/settings_misses/YYYYMMDD.json). It then
writes config/cluster/audit.json, `{"settings_misses": {key: count}}`
over the last seven UTC days, which the agent is to send as the
heartbeat's `audit`. Everything is bounded: 64 names, the rest and any
key that is not a settings name under "*". cron:cleanup prunes the day
files after eight days and rewrites audit.json every hour, so old days
drop out; in mode 0 the file is removed. MAIN and legacy nodes have no
flows.json and count nothing; a read costs one lookup.
On MAIN (Domain/Cluster/NodeAudit): a heartbeat's `audit` is normalised
(names and positive integer counts only, at most 16 KiB, the least
missed past 64 names folded into "*") and kept in the new
cluster_nodes.audit (migration 045, mirrored in database.sql; 044 is taken
by work on another branch). It is written only when it differs from what
the row holds, which every request reads already, so an unchanged report
costs no query, on the cluster bus or not. A heartbeat without `audit`,
today's agent's, changes nothing. The Cluster Nodes page shows each mode 1
or 2 node's misses.
SettingsAuditTest covers the counting per getter, withheld keys not
counted, mode 0 and MAIN counting nothing, the merge across processes,
the caps, the seven-day window, the pruning, the empty report and the
minute's merge of a long-running process. ClusterApiTest covers the
heartbeat storing a report once, an unchanged, missing or malformed one
writing nothing, an empty one clearing the page, and the cap; and
ReplicaBootTest a child process's miss reaching audit.json at its exit,
while booting from the replica reads only allowlisted settings.
The plan has src/service run `cluster:apply --from-disk` at boot, before
the daemons, so a node rebooted while MAIN is unreachable serves from its
replica. Until now the flag changed nothing: cluster:apply always applied
the `<name>.json` files the agent writes for PHP, which carry no
signature. At boot the agent may not run yet, and those files may predate
a key change or be torn.
With --from-disk, each section now comes from the sealed, panel-signed
record the agent stored beside its .json (Core/Cluster/ReplicaRecords):
the record must open with the node's box key from the agent's
agent.json (XCVM-SEAL-v1, purpose `replica`, context the node uuid), its
signature must verify under the panel key the agent pinned there (tag
`rep`, `blk` for the blocklist's deltas), and it must name this node and
the section. The blocklist is materialised again from blocklist.rep and
its deltas in seq order, as the agent does. A section is read only where
the agent stored its .json, which is what ReplicaApply::owns() and the
readers go by.
A record that does not verify is treated as an unreadable section: it
writes no cache and hands back one the replica owned, so its readers go
back to MAIN's database. The report lists the sections under `from_disk`
(`verified`, `unverified`), by name only, never their content. Without
--from-disk nothing changes: the agent runs cluster:apply right after it
verified what it stored.
ReplicaRecordsTest covers the records winning over a planted or stale
.json, a corrupt record, one signed by another panel key, one for another
node, another section's record, a missing record, a missing or broken
agent.json, the blocklist's deltas in order and out of order, and a
report with no secret in it. The test fixture (tests/Support/ReplicaFixture)
builds the records as MAIN does, with a panel key of its own.
MAIN now builds the rest of the replica's R1 whole sections (plan,
section 9) and serves them through the config op exactly as the
settings section: a sealed, panel-signed rep record per node, sent only
to an agent that names the section in have, and unchanged while the
node holds its ETag.
- servers: every server's routing and relay fields and the node list
{sid, gen, state, ed_pub}; node: the node's own row (ports, limits,
services, disk, HTTPS, interface, governor, sysctl, clock offset) and
its nginx settings; crontab: the enabled rows whose role fits the
node's mode; cluster: the transport policy, panel keys, min_proto and
off-air file names. Core/Cluster/ReplicaSections classifies every
servers column, so liveness, telemetry, watchdog_data, php_pids and
api_url never ship, and a new column fails the test until classified.
- The ETag (SHA-256 of the canonical data) and its section are reused
for 10 s (ReplicaEtagCache), and dropped by SettingsChangedEvent, the
new ServerSavedEvent and CrontabChangedEvent, which the admin's saves
now dispatch to a core listener.
- A revoked, re-enrolled or newly active node drops the cache and sends
every other node taking commands a restrictive config.changed at once;
cluster:exec acks it as deferred for an agent that does not run it.
Old agents name only blocklist and settings, so nothing changes for
them.
Bus heartbeats now belong to the enrolment that sent them. cl:hb
carries the node's gen, RECORD_LUA keeps root_ready and the pending
document only within the same gen, and a flush writes cluster_nodes
and servers.status only while the row's gen matches. Re-enrolment and
revocation drop the node's cl:hb, cl:hb_flushed and cl:tel:<sid>
(HeartbeatService::forget). A stale beat can no longer set root_ready,
last_seen_at or status 1 on a re-enrolled row.
After a root_ready change the flush reads the column back, so a beat
that hello overtook is not recorded as written, and the next heartbeat
is flushed at once instead of 5 s later. A node that is flushed and
forgotten in the same pass no longer leaves its cl:hb_flushed record
behind.
When a pass reads nothing from the bus (bus lost, or restarted empty),
LivenessService uses the heard times it last read from that bus for up
to FLUSH_EVERY_MS. MySQL alone could then be up to 12 s behind a live
node and turn it suspect.
Tests cover each guard the review found untested: the authority gate,
a refused servers write, telemetry judged on heard time, the forget
guards and cleanup, a future flusher stamp, the lock token, carried
root_ready and telemetry, re-enrolment and revocation, and bus loss.
Beats no longer depend on wall-clock time. QueryLogDb is loaded from
the test bootstrap.
ADR 0004 now states the agent contract precisely: a cadence of 3 s at
most, root_ready timing, and the size rule for MAIN's own encoding. It
also covers cl:tel under volatile-ttl and the bus-loss liveness bound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
EventIngest's class docblock said every batch commits with its cursor under
the lane lock; since the P2 lane (conn.touch) that holds for P0 and P1 only,
as the ADR says. The lane locks are real flock files, and every test class
that ingests P0/P1 events but ConnectionIngestIdempotencyTest left them in
the shared TMP_PATH or system temp dir, where suite runs from other
checkouts contend on the same files. The test bootstrap now points
EventIngest::useLockDir() at a directory of the process's own under
tests/.tmp, removed at exit; useLockDir() returns the directory it replaces
so ConnectionIngestIdempotencyTest puts the suite's back. The ADR's sixth
increment notes that touches on the bus and conn.divergence were built in
the tenth.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
Semaphores: ACQUIRE_LUA now reads the bus's own clock (TIME) inside the
script. A worker whose PHP clock read lagged another's could drop that
worker's live permits as taken in the future, so the limit of 4 leaked
under contention. The clock-step prune also allows 1 s (STEP_MS).
Nonces:
- The bus mark (nonces.bus) is written before the claim runs, and marks
only move forward unless more than a second ahead (a clock step).
- Without the bus, a mark from this second or the one before refuses up
to the end of this second plus the lead: the bus may be taking claims
right now, some not marked yet.
- Challenge values go to MySQL again, even with the bus: GET challenge is
unauthenticated, and on the bus a flood would evict reservations and
wake-ups under volatile-ttl.
- A REPLAY MAIN only cannot vouch for yet (a bus's floor, a lost or
unreachable bus) now carries retry_after_ms: a request stamped anew
that long after the denial's main_time_ms passes. A replay proper
carries none; today's agent ignores the field.
Tests pin what mutations showed was loose: every REPLAY call site passes
X-XCVM-Ts (fresh-bus heartbeat, token_rekey and both code ops), the young
and SQL-mark windows, the clock-step rule and its 1 s, the lead in each
rule, mark writes, the ops' lifetimes, and permits taken only after
authentication and the nonce. A tests/Support/BusServer helper replaces
the copied redis-server bootstrap in the new bus tests.
ADR 0004: the re-key and session check orders gain the permit step, the
OOM claim is corrected (claims are not refused past maxmemory; they are
bounded by authenticated traffic), and the agent contract covers REPLAY
with retry_after_ms and hello's 503 at every call site. The plan lists
the streams semaphore as not built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
Review of the cluster:reenrol increment.
- A node revoked from the panel, or quarantined by the API, after an
--all run started was still re-enrolled when the run reached it,
undoing the revocation. Each node's row is now read again just before
its turn: --all skips a node that left the chosen states, and any run
leaves alone a node that is no longer enrolled.
- One run at a time: cluster:reenrol holds a non-blocking lock, and
ServerEnrolCommand::enrol() holds one per node, so server:enrol and a
fleet run cannot interleave one node's keygen and startEnrolment.
- The credential file is read and deleted first, as server:enrol does,
so a real run refused for its arguments, a disabled API or a missing
extension no longer leaves the fleet's passwords on disk. A file
others can read is deleted too on such a run.
- enrol() judged success by created_at against the start of the flow,
which has one-second resolution. A node now counts as enrolled only
with a new node_uuid. A flow that enrols nothing (no xc_agent for the
node's arch, the API off) is a failure with the flow's own reason,
and a re-enrolled node is said to keep its previous identity, not to
stay legacy.
- The SSH port no longer falls back to bin/install/<id>.json, which
server:install deletes once the install succeeds and which a replaced
MAIN does not have. It is the node's entry, else the file's default,
else 22.
- execute() only checks the user; main() runs the rest and is tested:
a dry run stays dry, a real run deletes the file, a refused run
deletes it too, a second run refuses.
- Tests: nodes revoked, quarantined or removed during the run, an
exception on one node, each node's port and password, a flow that
enrols nothing, a password without a user, a file below bin/install/,
the report's order, the locks. FakeSshFleet gets a per-command hook
and is loaded by tests/bootstrap.php like the other support classes.
- Docs: the no-bundle procedure restores the database before
cluster:init, a rebuilt node's host key is stale rather than missing,
and the port rule and file deletion are stated as they are. ADR 0004
records the changes, and that --all does not skip nodes already
re-enrolled under the current root.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
ClusterApi serves health, challenge, enrol_complete, token_refresh, hello
and heartbeat. Nothing is written before the MAC (and, for token ops, the
node signature) verifies; refusals are panel-signed and bound to the
request nonce. Token refresh is idempotent per agent ephemeral key, so a
node holds at most two valid epochs. ClusterApiTest runs every flow against
a fake of the extension's token half and, opt-in, a real xcvm_core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
src/Core/Cluster/Crypto implements the wire formats of the extension's
cluster API v1 (xcvm_core ADR-002), in plain PHP with sodium and OpenSSL:
- Enc: the lp()/u64 canonical encoding.
- Box: XCVM-BOX-v1 session bodies.
- Seal: XCVM-SEAL-v1 to an X25519 key; refuses the all-zero shared secret
and binds purpose and context.
- PanelSig: verifies panel signatures against the closed tag registry.
- NodeSig: the node key's own signatures, under a separate domain string
so they can never pass as panel records.
- Canonical: the panel-owned request/response serialisation. It is the
BOX context and the MAC input, with a ±90 s window and header parsing.
- Ticket, RelayAuth, FileDigest: relay and file tickets, the child's
per-connect relay proof, and owner-signed file digests.
- ClusterCrypto: a facade over XC_VM::cluster_*. Refusals throw
ClusterRefusedException with the extension's reason code.
- ClusterCryptoFactory: fails closed (ClusterUnavailableException) without
the extension, outside API 1–1, or without sodium/AES-GCM. No PHP
fallback ships.
Tests:
- The panel matches the extension's shared vectors byte for byte
(tests/Support/cluster_vectors.json, copied from xcvm_core): SEAL, BOX,
panel signatures, the licence binding and CK_B, and the whole token
chain an agent runs.
- tests/Support/cluster_canonical_vectors.json freezes the canonical
request/response bytes for the Go agent.
- Also added: SignTagSeparationTest, ClusterTicketsTest,
ClusterCryptoFailClosedTest (including a check that nothing under
Core/Cluster signs as the panel or computes tokens) and
ClusterCryptoBenchTest.
- Timing: 64 KB is ~0.25 ms p99 against the 1 ms budget; 8 MB is ~41 ms
here against a 40 ms target, guarded at 2x.
The reference crypto that only xcvm_core or the Go agent computes
(binding, token MAC, session keys, panel signing with a seed) lives in
tests/Support and never ships.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
Exercised code paths echo diagnostics (dropOrphans() dropped-viewer lines,
GeoLite2 "[ERROR]" messages, ModuleLoader/GitHubReleases error_log output)
that clutter PHPUnit output without any test asserting on them:
- FanoutSyncOrphanTest / GeoLiteReleaseUpdaterTest: wrap each test in
ob_start()/ob_end_clean() via setUp()/tearDown().
- tests/bootstrap.php: route error_log() to /dev/null.
No production behaviour changes.
Clean up the admin stream loopback scripts (api/live/proxy_api/thumb/
timeshift/vod) for readability and to give their core logic unit-test
coverage, without changing behavior — except the two bugs called out below.
Readability:
- Invert the pervasive empty `if (c) {} else { body }` into `if (!c) { body }`.
- Flatten deep if/else nests into guard-clause / elseif chains (live, vod).
- Drop dead blank lines.
Testability (pure logic extracted, then covered):
- New value object XcVm\Domain\Stream\AdminStreamToken (decode + isValid)
replaces the uitoken decrypt+expiry+IP block duplicated across live/thumb/
timeshift/vod; it reuses the existing-but-unadopted NetworkUtils::ipMatches().
Token start/duration are kept raw (a timeshift date string must not be cast).
- Four pure helpers folded into the existing StreamUtils (not new one-method
classes): sanitizeSegmentName, containerMimeType, timeshiftStartTimestamp,
segmentRetryBudget — replacing inline path sanitization, a 44-line MIME
switch, the timeshift date parser, and the retry-budget math.
- Tests: AdminStreamTokenTest, NetworkUtilsTest (ipMatches had no coverage),
and StreamUtilsTest extended for the four helpers. OPENSSL_EXTRA is defined
in tests/bootstrap.php so token round-trips work.
Bug fixes (behavior changes):
- live: the retry budget used `$rTotalFails < intval(...) ?: 20`, which by
operator precedence parsed as `(... < ...) ?: 20` — always truthy — so the
`seg_time * 2` floor was ignored and the budget was always the configured
wait. Now max(seg_time*2, wait ?: 20) via StreamUtils::segmentRetryBudget().
- proxy_api: the per-second byte-rate divided by `time() - last`, which is 0
for two samples in the same second (division by zero). Clamp to >= 1s.
Three unit tests were failing against correct production code:
- FfmpegPathsTest errored on an undefined BIN_PATH constant. The test
bootstrap defined FFMPEG_BIN_* but not BIN_PATH, so FfmpegPaths::binary()
faulted before it could fall back. Define BIN_PATH to a fixture-less path
so version lookups miss and the FFMPEG_BIN_40 fallback is exercised.
- GitHubReleasesTest expected the old 'unstable' channel name; the channel
was renamed to 'beta' ('unstable' kept as a legacy alias). Update the
cache-switch test to expect 'beta' (and assert the alias normalizes), and
make the reject-invalid test use a genuinely unknown channel ('nightly').
Full suite now green (461 passing).
The XC_Autoloader fallback was already retired (no-op stub); this deletes it for
good. Resolution is now 100% Composer PSR-4 (+ ModuleLoader for modules), no
legacy scanner, no class-map cache.
- Move `define('MAIN_HOME', ...)` into bootstrap.php (autoload.php used to define
it); bootstrap.php now requires only vendor/autoload.php.
- Drop `\XC_Autoloader::clearCache()/warmCache()` from StartupCommand.
- tests/bootstrap.php: locate-guard + require switched to vendor/autoload.php.
- Entry points that required autoload.php directly — Public/index.php,
Public/admin/index.php, Public/stream/index.php, Public/progress/index.php,
ministra/portal.php and Admin/Reseller TableController — switched to
vendor/autoload.php (defining MAIN_HOME where they did not already). These were
not in the plan's checklist; found via grep during execution.
- phpstan.dist.neon: drop src/autoload.php from scanFiles.
- Makefile: drop autoload.php from LB_ROOT_FILES.
- deleted_files.txt: add autoload.php (client cleanup on update).
- AutoloadOrderTest: now asserts the XC_Autoloader class and file are gone.
- git rm src/autoload.php.
- PSR4_MIGRATION_PLAN.md: mark final-phase step 2 done.
Verified: grep XC_Autoloader:: = 0; php -l clean; PHPStan no errors; PHPUnit
303/303; make gates pass; bootstrap smoke — MAIN_HOME + XC_Bootstrap present,
XC_Autoloader gone, only the Composer autoloader registered.
- add chrisyue/php-m3u8 4.0.3 to committed vendor/ (PHP >=7.4, no
transitive dependencies)
- remove vendored Core/Parsing/PhpM3u8 snapshot and manual bootstrap
- autoload \Chrisyue\PhpM3u8\ from committed vendor/ instead of a path
mapping; library is used only by the test suite
- add gemorroj/m3u-parser 6.0.1 to committed vendor/ (PHP >=8.0.2;
upstream 6.1.0 requires PHP 8.2, incompatible with the 8.1 target)
- remove vendored Core/Parsing/M3uParser snapshot and manual bootstraps
- autoload \M3uParser\ from committed vendor/ instead of a path mapping
- stop tracking composer.lock (already gitignored); CI now audits the
committed vendor/composer/installed.json without --locked
Introduce a committed Composer PSR-4 autoloader without changing class
resolution behavior, as the foundation for the incremental PSR-4 migration.
- src/composer.json: PSR-4 (XcVm\ -> ./, M3uParser\, Chrisyue\PhpM3u8\),
platform php 8.1.33 (deploy runtime), optimize-autoloader/classmap-authoritative
false (live path resolution, no class-map cache). autoload.files left empty:
global functions are still loaded by existing require glue; moving them is
deferred until that glue is removed.
- src/vendor/ + src/composer.lock: committed (deploy path has no Composer);
generated with 'composer update' from src/. Regenerate with dump-autoload.
- src/bootstrap.php, tests/bootstrap.php: require vendor/autoload.php first,
then the legacy autoload.php.
- src/autoload.php: drop the igbinary disk cache (enableFileCache/saveCache/
shutdown handler/root-chown + bottom call); register at the END of the SPL
queue (prepend=false) so Composer wins for XcVm\* and only still-global
classes fall through to the in-memory scanner.
- Makefile: add vendor to LB_DIRS so load-balancer archives ship the loader.
- phpstan.dist.neon: exclude src/vendor/* from analysis.
- .gitignore: document that src/vendor/ is intentionally tracked.
- ci.yml: add composer-audit job (no-op until real require deps exist).
Verified: php -l clean; Composer first / XC_Autoloader last in the SPL stack;
tmp/cache/autoload_map no longer written; PHPUnit 292/292; PHPStan no errors.
Adds tests/Support/TestDb.php — a Database-compatible wrapper over
sqlite::memory: injected via setDb() — and StreamConfigRepositoryTest as a
worked example (SELECT/list/keyed/DELETE-cascade/not-found). 286 -> 292 tests.
- Added `StreamInterface` to define the contract for stream handling.
- Implemented `FileStream` class for file-based streaming.
- Implemented `TextStream` class for in-memory text streaming.
- Removed legacy `M3UParser` class and related resources.
- Added new tests for `AttributeStringToArray`, `Iso8601Transformer`, `Resolution`, `Byterange`, `Inf`, `Line`, and `Lines` classes.
- Updated bootstrap file to include new PhpM3u8 components.
- Replace legacy in-project M3U parser with vendored Gemorroj/M3uParser
- Initialize parser via bootstrap with proper namespacing
- Remove obsolete src/domain/Stream/M3UEntry.php implementation
- Update stream import/review mapping:
- use logo as fallback when tvg-logo is missing
- Add third-party compliance files for vendored parser:
- ATTRIBUTION.md
- LGPL LICENSE
- Add full M3uParser unit test suite:
- tag parsing
- parser behavior
- fixtures
- Add StreamService integration tests for M3U parsing/import flow
- Update test bootstrap:
- add igbinary_serialize / igbinary_unserialize polyfills
- explicitly load M3uParser bootstrap and custom test tag class
- add PHPUnit bootstrap and config under tests/
- add focused unit tests for GitHubReleases, InputValidator and FfmpegPaths
- fix GitHubReleases cache file handling when switching update channel
- document PHPUnit PHAR usage and debug run commands in RU/EN docs
- document SFTP sync for tests/ and contributor test workflow
- remove broad smoke coverage approach in favor of per-file tests