mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-01 09:38:21 +00:00
fix: bump server timeouts for better error handling (#3929)
This commit is contained in:
@@ -32,6 +32,10 @@
|
||||
"port": 4100,
|
||||
// Externally-visible port (set this when behind a reverse proxy on 80/443).
|
||||
"pub_port": 4100,
|
||||
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
|
||||
// proxy in front, or the proxy reuses connections this server has closed
|
||||
// and its clients see 502s.
|
||||
"keep_alive_timeout": 620000,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -585,3 +586,51 @@ describe('PuterServer HTTP alarm gate', () => {
|
||||
expect(fields).not.toHaveProperty('details');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A proxy in front pools upstream connections, so this server closing an idle
|
||||
* one first surfaces as a 502 to its clients. Run with a short timeout so the
|
||||
* close is observable; what matters is that the configured value reaches the
|
||||
* socket at all.
|
||||
*/
|
||||
describe('PuterServer keep-alive timeout', () => {
|
||||
let server: PuterServer;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await allocateEphemeralPort();
|
||||
server = await setupTestServer(
|
||||
{ port, keep_alive_timeout: 300 } as unknown as IConfig,
|
||||
{ listen: true },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('closes an idle keep-alive connection at the configured timeout', async () => {
|
||||
const socket = net.connect(port, '127.0.0.1');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
socket.write(
|
||||
'GET /healthcheck HTTP/1.1\r\nHost: puter.localhost\r\n\r\n',
|
||||
);
|
||||
await new Promise<void>((resolve) => socket.once('data', resolve));
|
||||
|
||||
let timer: NodeJS.Timeout;
|
||||
const closed = await Promise.race([
|
||||
new Promise<boolean>((resolve) =>
|
||||
socket.once('close', () => resolve(true)),
|
||||
),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), 3000);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer!);
|
||||
socket.destroy();
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,9 @@ import type {
|
||||
WithLifecycle,
|
||||
} from './types';
|
||||
|
||||
/** Idle keep-alive timeout used when `keep_alive_timeout` is unset. */
|
||||
const DEFAULT_KEEP_ALIVE_TIMEOUT = 620_000;
|
||||
|
||||
export class PuterServer {
|
||||
clients!: LayerInstances<typeof puterClients>;
|
||||
stores!: LayerInstances<typeof puterStores>;
|
||||
@@ -1410,6 +1413,13 @@ export class PuterServer {
|
||||
// to hook into the raw server (socket.io upgrades, WebSockets, …) runs
|
||||
// its `attachHttpServer(server)` here, pre-listen.
|
||||
const httpServer = http.createServer(this.#app);
|
||||
// Keep-alive has to outlive the idle timeout of any proxy in front: if
|
||||
// this server closes a pooled connection first, a request the proxy
|
||||
// dispatches onto it reaches the client as a 502. Node's 5s default is
|
||||
// below every common proxy setting. `headersTimeout` counts from a
|
||||
// request's first byte, so it needs no matching bump.
|
||||
httpServer.keepAliveTimeout =
|
||||
this.#config.keep_alive_timeout ?? DEFAULT_KEEP_ALIVE_TIMEOUT;
|
||||
for (const service of Object.values(this.services) as Array<
|
||||
WithLifecycle & {
|
||||
attachHttpServer?: (s: http.Server) => void | Promise<void>;
|
||||
|
||||
@@ -61,11 +61,13 @@ describe('ShareService', () => {
|
||||
|
||||
/** A real fsentry under the user's home, so ancestor chains resolve. */
|
||||
const makeFile = async (owner: { id: number; username: string }) => {
|
||||
const home = await server.stores.fsEntry.getRootEntryForUser(owner.id);
|
||||
if (!home) throw new Error('home directory missing');
|
||||
const uuid = uuidv4();
|
||||
const name = `f-${uuid.slice(0, 8)}.txt`;
|
||||
const path = `/${owner.username}/${name}`;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uuid,
|
||||
name,
|
||||
@@ -73,6 +75,8 @@ describe('ShareService', () => {
|
||||
owner.id,
|
||||
server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
home.id,
|
||||
home.uuid,
|
||||
],
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(path);
|
||||
@@ -85,6 +89,8 @@ describe('ShareService', () => {
|
||||
* shares.
|
||||
*/
|
||||
const makeDirWithFile = async (owner: { id: number; username: string }) => {
|
||||
const home = await server.stores.fsEntry.getRootEntryForUser(owner.id);
|
||||
if (!home) throw new Error('home directory missing');
|
||||
const dirUuid = uuidv4();
|
||||
const dirName = `d-${dirUuid.slice(0, 8)}`;
|
||||
const dirPath = `/${owner.username}/${dirName}`;
|
||||
@@ -93,7 +99,7 @@ describe('ShareService', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
dirUuid,
|
||||
dirName,
|
||||
@@ -101,6 +107,8 @@ describe('ShareService', () => {
|
||||
owner.id,
|
||||
server.clients.db.booleanValue(true),
|
||||
now,
|
||||
home.id,
|
||||
home.uuid,
|
||||
],
|
||||
);
|
||||
const dirRow = await server.stores.fsEntry.getEntryByPath(dirPath);
|
||||
|
||||
@@ -59,11 +59,13 @@ describe('ShareService: deleting a shared item', () => {
|
||||
|
||||
/** A real fsentry under the user's home, so ancestor chains resolve. */
|
||||
const makeFile = async (owner: { id: number; username: string }) => {
|
||||
const home = await server.stores.fsEntry.getRootEntryForUser(owner.id);
|
||||
if (!home) throw new Error('home directory missing');
|
||||
const uuid = uuidv4();
|
||||
const name = `f-${uuid.slice(0, 8)}.txt`;
|
||||
const path = `/${owner.username}/${name}`;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
uuid,
|
||||
name,
|
||||
@@ -71,6 +73,8 @@ describe('ShareService: deleting a shared item', () => {
|
||||
owner.id,
|
||||
server.clients.db.booleanValue(false),
|
||||
Math.floor(Date.now() / 1000),
|
||||
home.id,
|
||||
home.uuid,
|
||||
],
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(path);
|
||||
@@ -83,6 +87,8 @@ describe('ShareService: deleting a shared item', () => {
|
||||
* shares.
|
||||
*/
|
||||
const makeDirWithFile = async (owner: { id: number; username: string }) => {
|
||||
const home = await server.stores.fsEntry.getRootEntryForUser(owner.id);
|
||||
if (!home) throw new Error('home directory missing');
|
||||
const dirUuid = uuidv4();
|
||||
const dirName = `d-${dirUuid.slice(0, 8)}`;
|
||||
const dirPath = `/${owner.username}/${dirName}`;
|
||||
@@ -91,7 +97,7 @@ describe('ShareService: deleting a shared item', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
dirUuid,
|
||||
dirName,
|
||||
@@ -99,6 +105,8 @@ describe('ShareService: deleting a shared item', () => {
|
||||
owner.id,
|
||||
server.clients.db.booleanValue(true),
|
||||
now,
|
||||
home.id,
|
||||
home.uuid,
|
||||
],
|
||||
);
|
||||
const dirRow = await server.stores.fsEntry.getEntryByPath(dirPath);
|
||||
|
||||
@@ -627,6 +627,12 @@ interface IConfigOptional {
|
||||
* this to the public port.
|
||||
*/
|
||||
pub_port: number;
|
||||
/**
|
||||
* Idle keep-alive timeout for the HTTP server, in ms. Must stay above the
|
||||
* idle timeout of any proxy in front of it, or the proxy reuses connections
|
||||
* this server has already closed. Default 620000.
|
||||
*/
|
||||
keep_alive_timeout: number;
|
||||
/**
|
||||
* Teams and teams. Off means `/teams` 404s and the schema is inert, so the
|
||||
* tables can ship to production before anything can create a team. It is
|
||||
|
||||
Reference in New Issue
Block a user