Skip to content

Commit e94a18e

Browse files
Wire --body-limit into createServer; real CONTENT_LENGTH for chunked git pushes (JavaScriptSolidServer#562)
* fix: wire --body-limit into createServer; real CONTENT_LENGTH for chunked git pushes Two halves of the same user-facing bug (JavaScriptSolidServer#561) — 'git push of a pack over 10MB cannot succeed no matter what the operator configures': 1. bin/jss.js start parsed --body-limit / JSS_BODY_LIMIT into config.bodyLimit and then dropped it — the hand-written options object passed to createServer() omitted bodyLimit, so every CLI-started server enforced the 10MiB default. The JavaScriptSolidServer#474 knob was dead wiring; only programmatic createServer({ bodyLimit }) worked. 2. With the limit actually raised, pushes > 1MiB still died: git sends packs over http.postBuffer (1MiB) with Transfer-Encoding: chunked and no Content-Length, and the git handler set the CGI's CONTENT_LENGTH from the absent header ('0'), so http-backend read zero bytes and receive-pack failed with 'the remote end hung up unexpectedly'. Use the buffered body's real length instead. Regression tests (test/body-limit-cli.test.js) spawn the real CLI and assert the limit reaches Fastify via flag and env — 2 of 3 fail without fix 1. Verified end-to-end: 'jss install solid-chat/app=chat JavaScriptSolidServer/git' (13.6MB / >1MiB packs, previously impossible) now completes 2/2 with extracted working trees serving 200. Full suite: 961 pass, 0 fail. Fixes JavaScriptSolidServer#561 * review fix (JavaScriptSolidServer#562): bound stopCli's final await; document why the attach can't race Copilot flagged a race between the exitCode check and attaching the 'exit' listener. That specific interleaving can't occur — both are in the same synchronous block, and Node can't emit 'exit' between two synchronous statements (if the event already fired, exitCode is non-null and we return before attaching). Documented in a comment. The comment's spirit stands though: the final `await gone` was unbounded, so a pathological unkillable child (stuck in uninterruptible I/O) would hang the suite forever. Capped with a 2s race — SIGKILL can't be caught, so 'exit' follows promptly in every realistic case; the bound is belt-and-braces. 3/3 passing.
1 parent f3285c5 commit e94a18e

3 files changed

Lines changed: 143 additions & 1 deletion

File tree

bin/jss.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,10 @@ program
190190
const server = createServer({
191191
port: config.port,
192192
host: config.host,
193+
// Wire the parsed --body-limit / JSS_BODY_LIMIT value through —
194+
// omitting it here silently pinned every CLI-started server to
195+
// the 10MB default and made the #474 knob dead wiring (#561).
196+
bodyLimit: config.bodyLimit,
193197
logger: config.logger,
194198
conneg: config.conneg,
195199
notifications: config.notifications,

src/handlers/git.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,15 @@ export async function handleGit(request, reply) {
279279
CONTENT_TYPE: request.headers['content-type'] || '',
280280
QUERY_STRING: queryString,
281281
REMOTE_USER: request.webId || '', // Pass authenticated user
282-
CONTENT_LENGTH: request.headers['content-length'] || '0',
282+
// Use the buffered body's real length, not the Content-Length header:
283+
// git sends packs larger than http.postBuffer (1 MiB default) with
284+
// Transfer-Encoding: chunked and NO Content-Length, so the header
285+
// fallback told http-backend "0 bytes" and receive-pack died with
286+
// "the remote end hung up unexpectedly" on any push > 1 MiB (#561).
287+
// Fastify has already buffered the full body either way.
288+
CONTENT_LENGTH: request.body?.length
289+
? String(request.body.length)
290+
: (request.headers['content-length'] || '0'),
283291
};
284292

285293
// For regular repositories, set GIT_DIR

test/body-limit-cli.test.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* CLI wiring for --body-limit / JSS_BODY_LIMIT (#561).
3+
*
4+
* test/body-limit.test.js covers createServer({ bodyLimit }) — the
5+
* programmatic path — but the CLI start command builds its createServer
6+
* options object by hand, and originally omitted `bodyLimit`. Result:
7+
* the #474 knob parsed fine and was then silently dropped, pinning every
8+
* CLI-started server to the 10 MiB default. git pushes of packs over the
9+
* default failed with 413 no matter what the operator passed (#561).
10+
*
11+
* These tests spawn `bin/jss.js start` as a real subprocess (same
12+
* pattern as cli-flag-like-values.test.js) and assert the limit
13+
* actually reaches Fastify:
14+
*
15+
* - `--body-limit 1KB` → a 2 KiB PUT is rejected with 413
16+
* - `--body-limit 5MB` → the same PUT is NOT rejected with 413
17+
* - `JSS_BODY_LIMIT=1KB` (env, no flag) → 413, same as the flag
18+
*
19+
* The over-limit assertions are deliberately status-exact (413) while
20+
* the under-limit assertion is only "not 413": body parsing runs before
21+
* auth/WAC, so an under-limit request may still be refused later in the
22+
* pipeline — that's fine, the wiring under test is the parser cap.
23+
*/
24+
25+
import { describe, it, afterEach } from 'node:test';
26+
import assert from 'node:assert';
27+
import { spawn } from 'node:child_process';
28+
import { createServer as createNetServer } from 'node:net';
29+
import { fileURLToPath } from 'node:url';
30+
import path from 'node:path';
31+
import fs from 'fs-extra';
32+
33+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
34+
const BIN = path.join(__dirname, '..', 'bin', 'jss.js');
35+
const TEST_DATA_DIR = './test-data-body-limit-cli';
36+
37+
let child;
38+
39+
function freePort() {
40+
return new Promise((resolve, reject) => {
41+
const srv = createNetServer();
42+
srv.once('error', reject);
43+
srv.listen(0, '127.0.0.1', () => {
44+
const { port } = srv.address();
45+
srv.close(() => resolve(port));
46+
});
47+
});
48+
}
49+
50+
async function startCli({ args = [], env = {} }) {
51+
await fs.emptyDir(TEST_DATA_DIR);
52+
const port = await freePort();
53+
child = spawn(process.execPath, [
54+
BIN, 'start',
55+
'--port', String(port),
56+
'--host', '127.0.0.1',
57+
'--root', TEST_DATA_DIR,
58+
...args,
59+
], {
60+
env: { ...process.env, ...env },
61+
stdio: ['ignore', 'pipe', 'pipe'],
62+
});
63+
const baseUrl = `http://127.0.0.1:${port}`;
64+
65+
// Poll until the server answers (or the child dies / 15 s passes).
66+
const deadline = Date.now() + 15_000;
67+
let exited = false;
68+
child.once('exit', () => { exited = true; });
69+
while (Date.now() < deadline) {
70+
if (exited) throw new Error('jss CLI exited before becoming ready');
71+
try {
72+
await fetch(baseUrl, { signal: AbortSignal.timeout(1000) });
73+
return baseUrl;
74+
} catch {
75+
await new Promise(r => setTimeout(r, 250));
76+
}
77+
}
78+
throw new Error('jss CLI did not become ready within 15s');
79+
}
80+
81+
async function stopCli() {
82+
if (!child) return;
83+
const c = child;
84+
child = null;
85+
// The exitCode check and the once('exit') attach below are in the
86+
// same synchronous block — Node can't emit 'exit' between two
87+
// synchronous statements, so the listener can't miss the event (if
88+
// it already fired, exitCode is non-null and we return here).
89+
if (c.exitCode !== null || c.signalCode !== null) return;
90+
const gone = new Promise(r => c.once('exit', r));
91+
c.kill('SIGTERM');
92+
await Promise.race([gone, new Promise(r => setTimeout(r, 3000))]);
93+
if (c.exitCode === null && c.signalCode === null) c.kill('SIGKILL');
94+
// SIGKILL can't be caught, so 'exit' follows promptly. The cap is a
95+
// belt-and-braces bound so the suite can never hang on a pathological
96+
// unkillable child (e.g. stuck in uninterruptible I/O).
97+
await Promise.race([gone, new Promise(r => setTimeout(r, 2000))]);
98+
}
99+
100+
async function putTwoKiB(baseUrl) {
101+
const res = await fetch(`${baseUrl}/body-limit-probe.bin`, {
102+
method: 'PUT',
103+
headers: { 'Content-Type': 'application/octet-stream' },
104+
body: Buffer.alloc(2048, 0x61),
105+
signal: AbortSignal.timeout(5000),
106+
});
107+
return res.status;
108+
}
109+
110+
describe('bin/jss.js — --body-limit reaches Fastify (#561)', () => {
111+
afterEach(async () => {
112+
await stopCli();
113+
await fs.remove(TEST_DATA_DIR);
114+
});
115+
116+
it('--body-limit 1KB rejects a 2KiB PUT with 413', async () => {
117+
const baseUrl = await startCli({ args: ['--body-limit', '1KB'] });
118+
assert.strictEqual(await putTwoKiB(baseUrl), 413);
119+
});
120+
121+
it('--body-limit 5MB does not 413 a 2KiB PUT', async () => {
122+
const baseUrl = await startCli({ args: ['--body-limit', '5MB'] });
123+
assert.notStrictEqual(await putTwoKiB(baseUrl), 413);
124+
});
125+
126+
it('JSS_BODY_LIMIT=1KB (env, no flag) rejects a 2KiB PUT with 413', async () => {
127+
const baseUrl = await startCli({ env: { JSS_BODY_LIMIT: '1KB' } });
128+
assert.strictEqual(await putTwoKiB(baseUrl), 413);
129+
});
130+
});

0 commit comments

Comments
 (0)