Merge branch 'master' into fix/stream-reject-closes-connection

This commit is contained in:
Daniel authored and GitHub committed 2026-11-13 23:17:41 +01:00
commit 79ef9aaeb0
21 files changed
+465 -31

No files matched your search

+19
View File
@@ -48,6 +48,16 @@ Must be greater than 0.
Defaults to 60.
### controller.log_retention_days
Number of days to keep the controller and cluster log files in the log directory for.
Log files are named by the UTC date they were written and a file is deleted once its date is more than this many days in the past.
Old files are checked for on startup and every hour after that.
Set to 0 to keep logs forever.
Defaults to 30.
### controller.host_pairing_timeout
Time in seconds a host can wait for approval before its request expires, after which the host requests again with a new code.
@@ -449,6 +459,15 @@ Must be greater than 0.
Defaults to 60.
### host.log_retention_days
Number of days to keep the host log files in the log directory for.
Works the same way as controller.log_retention_days.
Set to 0 to keep logs forever.
Defaults to 30.
### host.allow_remote_updates
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
+5
View File
@@ -492,6 +492,11 @@ async function startup() {
controller.stop();
});
lib.startLogRetention(
[path.join(args.logDirectory, "cluster"), path.join(args.logDirectory, "controller")],
() => controllerConfig.get("controller.log_retention_days"),
);
await controller.start(args);
}
+43
View File
@@ -30,6 +30,42 @@ const wsRejectedConnectionsCounter = new lib.Counter(
"How many WebSocket connections have been rejected during the handshake on the controller"
);
const wsReadyCounter = new lib.Counter(
"clusterio_controller_websocket_ready_total",
"How many WebSocket sessions have been established on the controller",
{ labels: ["type"] }
);
const wsResumeCounter = new lib.Counter(
"clusterio_controller_websocket_resume_total",
"How many WebSocket sessions have been resumed on the controller",
{ labels: ["type"] }
);
const wsDropCounter = new lib.Counter(
"clusterio_controller_websocket_drop_total",
"How many WebSocket sessions have lost their connection without closing on the controller",
{ labels: ["type"] }
);
const wsSessionTimeoutCounter = new lib.Counter(
"clusterio_controller_websocket_session_timeout_total",
"How many WebSocket sessions have timed out waiting for a resume on the controller",
{ labels: ["type"] }
);
const wsFailedResumeCounter = new lib.Counter(
"clusterio_controller_websocket_failed_resume_total",
"How many WebSocket resume attempts have been rejected due to an unknown or expired session on the controller"
);
for (const type of ["host", "control"]) {
wsReadyCounter.labels(type);
wsResumeCounter.labels(type);
wsDropCounter.labels(type);
wsSessionTimeoutCounter.labels(type);
}
/**
* WebSocket server
* @alias module:controller/src/WsServer
@@ -240,6 +276,12 @@ ${err.stack}`
this.activeConnectors.delete(sessionId);
});
const type = dst.type === lib.Address.host ? "host" : "control";
connector.on("connect", () => wsReadyCounter.labels(type).inc());
connector.on("resume", () => wsResumeCounter.labels(type).inc());
connector.on("drop", () => wsDropCounter.labels(type).inc());
connector.on("timeout", () => wsSessionTimeoutCounter.labels(type).inc());
return [connector, sessionToken];
}
@@ -367,6 +409,7 @@ ${err.stack}`
}
} catch (err) {
wsFailedResumeCounter.inc();
socket.send(JSON.stringify(new lib.MessageInvalidate()));
this.attachHandler(socket, req);
return;
+6 -1
View File
@@ -7,13 +7,17 @@ import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
type WsServerConnectorEvents = {
"timeout": [],
};
/**
* Connector for controller connections
*
* @extends module:lib.WebSocketBaseConnector
* @alias module:controller/src/WsServerConnector
*/
export default class WsServerConnector extends lib.WebSocketBaseConnector {
export default class WsServerConnector extends lib.WebSocketBaseConnector<WsServerConnectorEvents> {
_timeoutId: ReturnType<typeof setTimeout> | null = null;
constructor(
@@ -119,6 +123,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
_timedOut() {
logger.verbose(`Connector | Connection to ${this.dst} timed out`);
this.emit("timeout");
this._close();
}
+4
View File
@@ -46,6 +46,10 @@ const factorioLocations = {
],
darwin: [
"/Applications/factorio.app/Contents",
path.join(
os.homedir(), "Library", "Application Support", "Steam", "steamapps", "common", "Factorio",
"factorio.app", "Contents",
),
],
};
+5
View File
@@ -210,6 +210,11 @@ async function startHost() {
// From this point on the host should run
await hostConfigLock.acquire();
lib.startLogRetention(
[path.join(args.logDirectory, "host")],
() => hostConfig.get("host.log_retention_days"),
);
// If we get here the command was run
await fs.mkdir(hostConfig.get("host.instances_directory"), { recursive: true });
await fs.mkdir(hostConfig.get("host.mods_directory"), { recursive: true });
+16
View File
@@ -38,6 +38,7 @@ export interface ControllerConfigFields {
"controller.default_mod_pack_id": number | null;
"controller.default_role_id": number | null;
"controller.autosave_interval": number;
"controller.log_retention_days": number;
"controller.host_pairing_timeout": number;
"controller.host_pairing_max_pending": number;
"controller.mod_portal_cache_duration_minutes": number;
@@ -101,6 +102,13 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
initialValue: 60,
validator: validators.greaterThanZero,
},
"controller.log_retention_days": {
title: "Log Retention Days",
description: "Days to keep controller and cluster log files for, 0 to keep them forever.",
type: "number",
initialValue: 30,
validator: validators.all(validators.integer, validators.greaterThanEqualZero),
},
"controller.host_pairing_timeout": {
title: "Host Pairing Timeout",
description: "Time in seconds a host can wait for approval before it has to request again, 0 for no limit.",
@@ -357,6 +365,7 @@ export interface HostConfigFields {
"host.factorio_username": string | null,
"host.factorio_token": string | null,
"host.max_reconnect_delay": number;
"host.log_retention_days": number;
"host.allow_remote_updates": boolean;
"host.allow_plugin_updates": boolean;
"host.allow_plugin_install": boolean;
@@ -466,6 +475,13 @@ export class HostConfig extends classes.Config<HostConfigFields> {
initialValue: 60,
validator: validators.greaterThanZero,
},
"host.log_retention_days": {
title: "Log Retention Days",
description: "Days to keep host log files for, 0 to keep them forever.",
type: "number",
initialValue: 30,
validator: validators.all(validators.integer, validators.greaterThanEqualZero),
},
"host.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
type: "boolean",
+82 -1
View File
@@ -358,8 +358,15 @@ export class LogIndex {
*/
async buildIndex() {
let today = new Date(Date.now() - 300e3).toISOString().slice(0, 10);
let files = await fs.readdir(this.logDirectory);
for (let file of await fs.readdir(this.logDirectory)) {
for (let file of this.index.keys()) {
if (!files.includes(file)) {
this.index.delete(file);
}
}
for (let file of files) {
let match = logFileGlob.exec(file);
if (!match || match[1] >= today || this.index.has(file)) {
continue;
@@ -412,6 +419,80 @@ export class LogIndex {
}
}
/**
* Delete dated log files older than the given number of days
*
* Only files named like name-YYYY-MM-DD.log are deleted, and only once
* every entry they can hold is older than retentionDays.
*
* @param logDirectory - Directory to delete log files from.
* @param retentionDays - Days to keep log files for, 0 keeps them forever.
* @param now - Current time in milliseconds since the epoch.
* @returns names of the deleted files.
*/
export async function deleteOldLogs(logDirectory: string, retentionDays: number, now = Date.now()) {
if (!(retentionDays > 0)) {
return [];
}
let files: string[];
try {
files = await fs.readdir(logDirectory);
} catch (err: any) {
if (err.code === "ENOENT") {
return [];
}
throw err;
}
const cutoff = new Date(now - retentionDays * 86400e3).toISOString().slice(0, 10);
const deleted: string[] = [];
for (const file of files) {
const match = logFileGlob.exec(file);
if (!match || match[1] >= cutoff) {
continue;
}
try {
await fs.unlink(path.join(logDirectory, file));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
deleted.push(file);
}
return deleted;
}
/**
* Delete old logs now and then every hour
*
* @param logDirectories - Directories to delete old log files from.
* @param getRetentionDays - Called before each run to get the retention.
* @returns the interval timer, which does not keep the process alive.
*/
export function startLogRetention(logDirectories: string[], getRetentionDays: () => number) {
async function run() {
const retentionDays = getRetentionDays();
for (const logDirectory of logDirectories) {
try {
const deleted = await deleteOldLogs(logDirectory, retentionDays);
if (deleted.length) {
logger.info(
`Deleted ${deleted.length} log files older than ${retentionDays} days from ${logDirectory}`
);
}
} catch (err: any) {
logger.error(`Failed to delete old logs from ${logDirectory}:\n${err.stack}`);
}
}
}
run();
const interval = setInterval(run, 3600e3);
interval.unref();
return interval;
}
/**
* Filter object for querying logs
*/
+34 -7
View File
@@ -460,6 +460,39 @@ export interface SubscribableValue {
* Multiple handlers can be subscribed at the same time
*/
export class EventSubscriber<E, S = null> {
/** Subscribers created on each connector, they share one connect and one close listener on it */
private static connectorSubscribers = new WeakMap<object, Set<EventSubscriber<any, any>>>();
/**
* Pass connect and close events of a connector on to a subscriber
* Uses one listener for all subscribers, so that plugins creating many subscribers do not go past the
* listener limit set on the connector.
* @param connector - connector of the link the subscriber was created on
* @param subscriber - subscriber to pass the events to
*/
private static addConnectorSubscriber(
connector: WebSocketBaseConnector,
subscriber: EventSubscriber<any, any>,
) {
let subscribers = EventSubscriber.connectorSubscribers.get(connector);
if (!subscribers) {
const created = new Set<EventSubscriber<any, any>>();
EventSubscriber.connectorSubscribers.set(connector, created);
connector.on("connect", () => {
for (const entry of created) {
entry.handleConnectionEvent("connect");
}
});
connector.on("close", () => {
for (const entry of created) {
entry.handleConnectionEvent("close");
}
});
subscribers = created;
}
subscribers.add(subscriber);
}
/** The time at which an event was last received, is less than 0 when there have been no events */
lastUpdatedMs = -1;
/** True if this subscriber is currently synced with the source */
@@ -485,13 +518,7 @@ export class EventSubscriber<E, S = null> {
) {
control.handle(Event, this._handleEvent.bind(this));
// The events below exist only on websocket connectors, we can't early return because of test mocking
const webSocketConnector = control.connector as WebSocketBaseConnector;
webSocketConnector.on("connect", () => {
this.handleConnectionEvent("connect");
});
webSocketConnector.on("close", () => {
this.handleConnectionEvent("close");
});
EventSubscriber.addConnectorSubscriber(control.connector as WebSocketBaseConnector, this);
// Only the web Control reports account changes
const accountControl = control as Link & { onAccountUpdate?: (handler: () => void) => void };
if (typeof accountControl.onAccountUpdate === "function") {
@@ -7,6 +7,7 @@ import type { ColumnType } from "antd/es/table/interface";
import { useAccount } from "../model/account";
import { useSystems } from "../model/system";
import { useHosts } from "../model/host";
import { useUsers } from "../model/user";
import { useDefaultModPack, useModPacks } from "../model/mod_pack";
import { RestartRequired } from "./system_metrics";
import InstanceStatusTag from "./InstanceStatusTag";
@@ -33,6 +34,17 @@ export default function InstanceList(props: InstanceListProps) {
const [systems] = useSystems();
const [modPacks] = useModPacks();
const defaultModPack = useDefaultModPack();
const [users] = useUsers();
const canUsers = account.hasPermission("core.user.subscribe");
const onlineCounts = React.useMemo(() => {
const counts = new Map<number, number>();
for (const user of users.values()) {
for (const instanceId of user.instances ?? []) {
counts.set(instanceId, (counts.get(instanceId) ?? 0) + 1);
}
}
return counts;
}, [users]);
const tableState = useTableQueryState<lib.InstanceDetails>({
namespace: "instance", defaultSortKey: "name",
});
@@ -146,6 +158,18 @@ export default function InstanceList(props: InstanceListProps) {
},
];
if (canUsers) {
const playerCount = (instance: lib.InstanceDetails) => onlineCounts.get(instance.id) ?? 0;
columns.push({
title: "Players",
key: "players",
render: (_, instance) => playerCount(instance),
sorter: (a, b) => playerCount(a) - playerCount(b),
sortOrder: tableState.sortOrder("players"),
responsive: ["md"],
});
}
if (account.hasAnyPermission(...InstanceControlButtonPermissions)) {
columns.push({
key: "action",
@@ -182,6 +182,7 @@ function SearchModsTable(props: SearchModsTableProps) {
return <>
<Input
autoFocus
placeholder="Search Mods"
style={{ marginBottom: 8 }}
onChange={(event) => {
+6 -3
View File
@@ -1,7 +1,8 @@
import React, { Fragment, useState, useContext, useEffect, useMemo } from "react";
import React, { Fragment, useState, useContext, useEffect, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, notification,
Button, Form, Input, InputRef, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip,
notification,
} from "antd";
import {
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
@@ -147,6 +148,7 @@ function SearchModsButton() {
const account = useAccount();
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const nameInputRef = useRef<InputRef>(null);
const [searchText, setSearchText] = useState("");
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion>("2.0");
@@ -333,6 +335,7 @@ function SearchModsButton() {
title="Search Mod Portal"
open={open}
onCancel={() => { setOpen(false); }}
afterOpenChange={visible => { if (visible) { nameInputRef.current?.focus(); } }}
width={1000}
footer={[
<Button key="close" onClick={() => { setOpen(false); }}>
@@ -349,7 +352,7 @@ function SearchModsButton() {
initialValues={{ factorioVersion: factorioVersion, name: searchText }}
>
<Form.Item name="name" label="Filter by Name or Title">
<Input placeholder="Start typing to filter..." />
<Input ref={nameInputRef} placeholder="Start typing to filter..." />
</Form.Item>
<Form.Item
name="factorioVersion"
@@ -1,4 +1,5 @@
import React from "react";
import React, { useState } from "react";
import { Collapse } from "antd";
import { useUsers } from "../model/user";
import UsersTable from "./UsersTable";
@@ -9,23 +10,26 @@ type OnlinePlayersListProps = {
export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps) {
const [users] = useUsers();
// undefined until the user toggles the panel, then their choice sticks.
const [open, setOpen] = useState<boolean>();
// Filter users to only show those online on this specific instance
const onlineUsers = [...users.values()].filter(user => user.instances && user.instances.has(instanceId));
const onlineCount = [...users.values()].filter(user => user.instances?.has(instanceId)).length;
const expanded = open ?? onlineCount > 0;
if (onlineUsers.length === 0) {
return null;
}
return (
<div style={{ marginTop: 16 }}>
<h4>Online Players ({onlineUsers.length})</h4>
<UsersTable instanceId={instanceId} onlyOnline size="small" pagination={{
return <Collapse
size="small"
style={{ marginTop: 16 }}
activeKey={expanded ? ["players"] : []}
onChange={keys => setOpen(keys.length > 0)}
items={[{
key: "players",
label: `Online Players (${onlineCount})`,
children: <UsersTable instanceId={instanceId} onlyOnline size="small" pagination={{
defaultPageSize: 10,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100", "200"],
showTotal: (total: number) => `${total} Users`,
}} />
</div>
);
}} />,
}]}
/>;
}
+1 -1
View File
@@ -170,7 +170,7 @@ Defaults to `true`.
### player_sync.sync_quick_bar
Sync the quick bar.
Sync the quick bar and the page shown on each of its rows.
Defaults to `true`.
+1 -1
View File
@@ -9,7 +9,7 @@ export const components = {
// inventories holds the god inventory and the hidden ghost inventory
inventories: ["character", "crafting_queue", "inventories"],
logistics: ["personal_logistic_slots"],
quick_bar: ["quick_bar", "hotbar"],
quick_bar: ["quick_bar", "quick_bar_pages", "hotbar"],
settings: ["flashlight", "shortcuts", "game_view_settings"],
recipe_notifications: ["recipe_notifications"],
} as const;
+2
View File
@@ -23,6 +23,7 @@ export type IpcPlayerData = {
character?: any,
inventories?: any,
quick_bar?: any,
quick_bar_pages?: number[],
hotbar?: Record<string, string>,
personal_logistic_slots?: any,
crafting_queue?: any,
@@ -181,6 +182,7 @@ const jsonImportedPlayerData = Type.Object({
character: Type.Optional(jsonCharacter),
inventories: Type.Optional(jsonInventories),
quick_bar: Type.Optional(Type.Record(Type.String(), jsonQuickBarSlot)),
quick_bar_pages: Type.Optional(Type.Array(Type.Integer({ minimum: 1, maximum: 10 }), { maxItems: 10 })),
hotbar: Type.Optional(Type.Record(Type.String(), Type.String())),
personal_logistic_slots: Type.Optional(jsonPersonalLogisticSlots),
crafting_queue: Type.Optional(jsonCraftingQueue),
+52 -3
View File
@@ -662,13 +662,20 @@ function serialize.deserialize_quick_bar_slot(entry, player)
return entry --[[@as string]]
end
--- Slots over all 10 quick bar pages, a page is as wide as the player set it from 2.1 and 10 slots before
--- @param player LuaPlayer
--- @return integer
local function quick_bar_slot_count(player)
return 10 * (v2_1_quick_bar_api and player.quick_bar_width or 10)
end
--- @param player LuaPlayer
--- @return table<string, QuickBarSlotEncoded>?
function serialize.serialize_quick_bar(player)
local serialized
local width = v2_1_quick_bar_api and player.quick_bar_width
for i = 1, 100 do
for i = 1, quick_bar_slot_count(player) do
local slot
if v2_1_quick_bar_api then
@@ -699,7 +706,7 @@ function serialize.deserialize_quick_bar(player, serialized)
-- previous server does not come back from this server's old copy
serialized = serialized or {}
local width = v2_1_quick_bar_api and player.quick_bar_width
for i = 1, 100 do
for i = 1, quick_bar_slot_count(player) do
local entry = serialized[tostring(i)]
local slot = entry ~= nil and serialize.deserialize_quick_bar_slot(entry, player) or nil
if v2_1_quick_bar_api then
@@ -713,6 +720,30 @@ function serialize.deserialize_quick_bar(player, serialized)
end
end
--- Quick bar page shown on each quick bar row of the screen, kept by every save on its own
--- @param player LuaPlayer
--- @return integer[]?
function serialize.serialize_quick_bar_pages(player)
local pages = {}
for screen = 1, 10 do
-- pcall: the number of rows the API knows is not documented for older versions
local ok, page = pcall(player.get_active_quick_bar_page, screen)
if not ok or not page then
break
end
pages[screen] = page
end
return pages[1] and pages or nil
end
--- @param player LuaPlayer
--- @param pages integer[]? Absent in data from before pages were synced, the rows then stay as they are here
function serialize.deserialize_quick_bar_pages(player, pages)
for screen, page in ipairs(pages or {}) do
player.set_active_quick_bar_page(screen, page)
end
end
-- Crafting queue is a table with the following fields:
-- crafting_queue
-- ingredients
@@ -977,6 +1008,7 @@ end
--- @field inventories table<string, table>?
--- @field hotbar table<string, string>?
--- @field quick_bar table<string, QuickBarSlotEncoded>?
--- @field quick_bar_pages integer[]? Page shown on each quick bar row
--- @field personal_logistic_slots table?
--- @field crafting_queue table?
--- @field recipe_notifications string?
@@ -1181,6 +1213,7 @@ function serialize.serialize_player(player, failed_deserialization, components)
-- Serialize quick bar
if syncs(components, "quick_bar") then
serialized.quick_bar = serialize.serialize_quick_bar(player)
serialized.quick_bar_pages = serialize.serialize_quick_bar_pages(player)
end
-- Serialize crafting queue
@@ -1271,8 +1304,23 @@ local function ensure_character(player)
player.teleport(force.get_spawn_position(planet_surface), planet_surface)
end
-- Create and return the character
-- create_character needs a free spot within 200 tiles and does not generate chunks to find one. It fails in a chunk
-- which does not exist yet, such as one the player was teleported into a moment ago, and where water or void is all
-- there is. The character is then made at the force's spawn. From a missing chunk it is moved back, as the game
-- generates the chunks around a character. Otherwise there is nowhere to stand, and it stays at the spawn.
if not player.create_character() then
surface = player.surface
local position = player.position --[[@as MapPosition.struct]]
local force = player.force --[[@as LuaForce]]
local missing_chunk = not surface.is_chunk_generated({ math.floor(position.x / 32), math.floor(position.y / 32) })
player.teleport(force.get_spawn_position(surface), surface)
if player.create_character() and missing_chunk then
player.teleport(position, surface)
end
end
-- Return the character
if not player.character then
error(string.format(
"Failed to create character (controller: %s, physical: %s, surface: %s, connected: %s, driving: %s)",
controller_to_name[player.controller_type],
@@ -1448,6 +1496,7 @@ function serialize.deserialize_player(player, serialized, components, local_cont
-- Deserialize quick bar (named hotbar in old data)
if syncs(components, "quick_bar") then
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
serialize.deserialize_quick_bar_pages(player, serialized.quick_bar_pages)
end
-- Deserialize recipe notifications
+3
View File
@@ -117,6 +117,7 @@ describe("player_sync plugin", function() {
3: { t: "i", n: "main", s: 4, b: [2] },
4: { t: "r", p: [1], y: "blueprint" },
},
quick_bar_pages: [2, 1, 3],
crafting_queue: {
crafting_queue: [{ recipe: "iron-gear-wheel", count: 5 }],
crafting_queue_progress: 0.25,
@@ -172,6 +173,8 @@ describe("player_sync plugin", function() {
["quick_bar", { 1: { t: "i", n: "main" } }],
["quick_bar", { 1: { t: "r", y: "blueprint" } }],
["quick_bar", { 1: 5 }],
["quick_bar_pages", [0]],
["quick_bar_pages", [11]],
["personal_logistic_slots", { enabled: true, sections: [] }],
["personal_logistic_slots", { enabled: true, trash_not_requested: false, sections: [{}] }],
["personal_logistic_slots", { 1: { min: 50 } }],
+52
View File
@@ -113,6 +113,58 @@ describe("controller/src/WsServer", function() {
});
});
});
describe("connection metrics", function() {
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
/** @type {WsServer} */
let wsServer;
before(function() {
const config = new ControllerConfig("controller");
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
wsServer = new Controller(lib.logger, [], config).wsServer;
});
function counter(name) {
return lib.defaultRegistry.collectors.find(collector => collector.metric.name === name);
}
function value(name, type) {
const collector = counter(`clusterio_controller_websocket_${name}_total`);
return type ? collector.labels(type).get() : collector.get();
}
[["host", lib.Address.host], ["control", lib.Address.control]].forEach(([type, addressType]) => {
it(`should count ready, drop, resume and timeout for ${type} sessions`, function() {
const before = Object.fromEntries(
["ready", "drop", "resume", "session_timeout"].map(name => [name, value(name, type)])
);
const [connector, sessionToken] = wsServer.createSession(new lib.Address(addressType, 1));
connector.ready(new MockWebSocket(), new lib.Address(addressType, 1), sessionToken, undefined);
assert.equal(value("ready", type), before.ready + 1);
connector._socket.terminate();
assert.equal(value("drop", type), before.drop + 1);
wsServer.resumeSession(new lib.ResumeData(sessionToken, 0), new MockWebSocket(), req);
assert.equal(value("resume", type), before.resume + 1);
connector._socket.terminate();
connector._timedOut();
assert.equal(value("session_timeout", type), before.session_timeout + 1);
assert.equal(value("drop", type), before.drop + 2);
assert.equal(value("ready", type), before.ready + 1);
});
});
it("should count resumes of unknown sessions as failed", function() {
const before = value("failed_resume");
const socket = new MockWebSocket();
wsServer.resumeSession(new lib.ResumeData("invalid", 0), socket, req);
try {
assert.equal(value("failed_resume"), before + 1);
assert.equal(socket.sent[0].type, "invalidate");
} finally {
socket.emit("message", "not json"); // ends the handshake and its timeout
}
});
});
describe(".registerHost()", function() {
const secret = Buffer.from("TestSecretDoNotUse");
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
+50
View File
@@ -45,6 +45,19 @@ describe("lib/logging_utils.js", function() {
logIndex
);
});
it("should drop entries for files no longer present", async function() {
let tempDir = path.join("temp", "test", "log_index_prune");
await fs.rm(tempDir, { recursive: true, force: true });
await fs.cp(logDir, tempDir, { recursive: true });
let logIndex = await lib.LogIndex.load(tempDir);
await logIndex.buildIndex();
await fs.unlink(path.join(tempDir, "test-2021-12-10.log"));
await logIndex.buildIndex();
assert.deepEqual(
new Set(logIndex.index.keys()),
new Set(["test-2021-12-11.log", "test-2021-12-12.log"])
);
});
it("should handle loading broken index", async function() {
let tempDir = path.join("temp", "test", "log");
await fs.writeFile(path.join(tempDir, "index.json"), "Broken JSON");
@@ -53,6 +66,43 @@ describe("lib/logging_utils.js", function() {
});
});
describe("deleteOldLogs", function() {
let tempDir = path.join("temp", "test", "log_retention");
let files = [
"cluster-2026-10-01.log",
"cluster-2026-10-12.log",
"cluster-2026-10-13.log",
"cluster-2026-11-12.log",
"index.json",
"notes-2026-10-01.txt",
".0123abcd-audit.json",
];
let now = Date.parse("2026-11-12T08:00:00Z");
beforeEach(async function() {
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(tempDir, { recursive: true });
for (let file of files) {
await fs.writeFile(path.join(tempDir, file), "");
}
});
it("should delete only dated log files past the retention", async function() {
let deleted = await lib.deleteOldLogs(tempDir, 30, now);
assert.deepEqual(deleted.sort(), ["cluster-2026-10-01.log", "cluster-2026-10-12.log"]);
assert.deepEqual(
(await fs.readdir(tempDir)).sort(),
files.filter(file => !deleted.includes(file)).sort(),
);
});
it("should keep everything with a retention of 0", async function() {
assert.deepEqual(await lib.deleteOldLogs(tempDir, 0, now), []);
assert.deepEqual((await fs.readdir(tempDir)).sort(), [...files].sort());
});
it("should ignore a missing directory", async function() {
assert.deepEqual(await lib.deleteOldLogs(path.join(tempDir, "missing"), 30, now), []);
});
});
let allLines = [];
let logIndex = null;
before(async function() {
+41
View File
@@ -1021,6 +1021,47 @@ describe("lib/subscriptions", function() {
it("should handle the provided event", function() {
assert.equal(mockControl._eventHandlers.has(RegisteredEvent), true);
});
it("should share one connect and close listener on the connector", function() {
const connector = mockControl.connector;
const connectListeners = connector.listenerCount("connect");
const closeListeners = connector.listenerCount("close");
const subscribers = [
new lib.EventSubscriber(StringPermissionEvent, mockControl),
new lib.EventSubscriber(FunctionPermissionEvent, mockControl),
];
assert.equal(subscribers.length, 2);
assert.equal(connector.listenerCount("connect"), connectListeners);
assert.equal(connector.listenerCount("close"), closeListeners);
});
it("should pass connect and close to every subscriber on the connector", function() {
const subscribers = [
eventSubscriber,
new lib.EventSubscriber(StringPermissionEvent, mockControl),
new lib.EventSubscriber(FunctionPermissionEvent, mockControl),
];
const received = subscribers.map(() => []);
subscribers.forEach((subscriber, index) => {
subscriber.handleConnectionEvent = event => { received[index].push(event); };
});
mockControl.connector.emit("close");
mockControl.connector.emit("connect");
assert.deepEqual(received, [["close", "connect"], ["close", "connect"], ["close", "connect"]]);
});
it("should only pass events to subscribers on the same connector", function() {
const otherControl = new MockControl(new MockConnector(
addr({ controlId: 1 }),
addr("controller"),
));
const other = new lib.EventSubscriber(RegisteredEvent, otherControl);
const received = [];
eventSubscriber.handleConnectionEvent = event => { received.push(["first", event]); };
other.handleConnectionEvent = event => { received.push(["other", event]); };
otherControl.connector.emit("close");
assert.deepEqual(received, [["other", "close"]]);
});
});
describe("subscribe()", function() {