diff --git a/config.template.jsonc b/config.template.jsonc index 54f65d877..afdb826f9 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -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/ diff --git a/src/backend/server.test.ts b/src/backend/server.test.ts index 8a5d0fb76..1119cf09f 100644 --- a/src/backend/server.test.ts +++ b/src/backend/server.test.ts @@ -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((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((resolve) => socket.once('data', resolve)); + + let timer: NodeJS.Timeout; + const closed = await Promise.race([ + new Promise((resolve) => + socket.once('close', () => resolve(true)), + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 3000); + }), + ]); + clearTimeout(timer!); + socket.destroy(); + expect(closed).toBe(true); + }); +}); diff --git a/src/backend/server.ts b/src/backend/server.ts index 3e49000b3..ad9429379 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -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; stores!: LayerInstances; @@ -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; diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index 58557c580..fe9adce7e 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -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); diff --git a/src/backend/services/share/ShareService.trash.test.ts b/src/backend/services/share/ShareService.trash.test.ts index 299e8404e..b0f31f4f7 100644 --- a/src/backend/services/share/ShareService.trash.test.ts +++ b/src/backend/services/share/ShareService.trash.test.ts @@ -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); diff --git a/src/backend/types.ts b/src/backend/types.ts index 093b421ec..7fe36b01d 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -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