fix: bump server timeouts for better error handling (#3929)

This commit is contained in:
Daniel Salazar
2026-09-22 10:13:46 -07:00
committed by GitHub
parent 8abdcf4f7f
commit 718de8a9c6
6 changed files with 89 additions and 4 deletions
+4
View File
@@ -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/
+49
View File
@@ -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);
});
});
+10
View File
@@ -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);
+6
View File
@@ -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