javascript-solid-server 0.0.150 → 0.0.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.150",
3
+ "version": "0.0.151",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -8,6 +8,10 @@ import { join } from 'path';
8
8
  import { getDataRoot } from '../utils/url.js';
9
9
 
10
10
  const QUOTA_FILE = '.quota.json';
11
+ // Temp files created by saveQuota live next to the quota file; they must be
12
+ // excluded from disk-usage reconciliation so an orphan (e.g. from a crash
13
+ // between writeFile and rename) doesn't inflate pod usage.
14
+ const QUOTA_TMP_PREFIX = `${QUOTA_FILE}.tmp.`;
11
15
 
12
16
  /**
13
17
  * Get quota file path for a pod
@@ -16,6 +20,18 @@ function getQuotaPath(podName) {
16
20
  return join(getDataRoot(), podName, QUOTA_FILE);
17
21
  }
18
22
 
23
+ /**
24
+ * Coerce a parsed quota object to a sane shape so all callers can do
25
+ * arithmetic on it without worrying about undefined/NaN/negative values.
26
+ */
27
+ function sanitizeQuota(q) {
28
+ const toNum = (v) => {
29
+ const n = Number(v);
30
+ return Number.isFinite(n) && n >= 0 ? n : 0;
31
+ };
32
+ return { limit: toNum(q?.limit), used: toNum(q?.used) };
33
+ }
34
+
19
35
  /**
20
36
  * Load quota data for a pod
21
37
  * @param {string} podName - The pod name
@@ -24,11 +40,15 @@ function getQuotaPath(podName) {
24
40
  export async function loadQuota(podName) {
25
41
  try {
26
42
  const data = await fs.readFile(getQuotaPath(podName), 'utf-8');
27
- return JSON.parse(data);
43
+ // Empty/partial file can appear if a write was interrupted (or from a
44
+ // racing non-atomic save on older versions). Treat as missing and
45
+ // reconcile against on-disk usage so we don't under-count.
46
+ if (!data.trim()) return { limit: 0, used: await calculatePodSize(podName) };
47
+ return sanitizeQuota(JSON.parse(data));
28
48
  } catch (err) {
29
- if (err.code === 'ENOENT') {
30
- // No quota file - return defaults (will be initialized on first write)
31
- return { limit: 0, used: 0 };
49
+ if (err.code === 'ENOENT') return { limit: 0, used: 0 };
50
+ if (err instanceof SyntaxError) {
51
+ return { limit: 0, used: await calculatePodSize(podName) };
32
52
  }
33
53
  throw err;
34
54
  }
@@ -40,7 +60,19 @@ export async function loadQuota(podName) {
40
60
  * @param {object} quota - Quota data
41
61
  */
42
62
  export async function saveQuota(podName, quota) {
43
- await fs.writeFile(getQuotaPath(podName), JSON.stringify(quota, null, 2));
63
+ // Atomic write: fs.writeFile truncates before writing, which lets concurrent
64
+ // readers see an empty file. Write to a temp file and rename (POSIX-atomic).
65
+ // Any failure (writeFile or rename) cleans up the temp file so failed writes
66
+ // don't accumulate orphans.
67
+ const finalPath = getQuotaPath(podName);
68
+ const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
69
+ try {
70
+ await fs.writeFile(tmpPath, JSON.stringify(quota, null, 2));
71
+ await fs.rename(tmpPath, finalPath);
72
+ } catch (err) {
73
+ await fs.unlink(tmpPath).catch(() => {});
74
+ throw err;
75
+ }
44
76
  }
45
77
 
46
78
  /**
@@ -76,8 +108,9 @@ async function calculateDirSize(dirPath) {
76
108
  for (const entry of entries) {
77
109
  const fullPath = join(dirPath, entry.name);
78
110
 
79
- // Skip quota file itself
111
+ // Skip the quota file and any orphaned temp files from saveQuota.
80
112
  if (entry.name === QUOTA_FILE) continue;
113
+ if (entry.name.startsWith(QUOTA_TMP_PREFIX)) continue;
81
114
 
82
115
  if (entry.isDirectory()) {
83
116
  total += await calculateDirSize(fullPath);
@@ -106,9 +139,12 @@ async function calculateDirSize(dirPath) {
106
139
  export async function checkQuota(podName, additionalBytes, defaultQuota) {
107
140
  let quota = await loadQuota(podName);
108
141
 
109
- // Initialize if no quota set
142
+ // Initialize if no quota set. loadQuota already sanitizes shape, so `used`
143
+ // is a finite non-negative number here — preserve it so reconciled usage
144
+ // from a recovered corrupt/empty file is not reset to 0.
110
145
  if (quota.limit === 0 && defaultQuota > 0) {
111
- quota = await initializeQuota(podName, defaultQuota);
146
+ quota = { limit: defaultQuota, used: quota.used };
147
+ await saveQuota(podName, quota);
112
148
  }
113
149
 
114
150
  // No quota enforcement if limit is 0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Regression tests for #309 — concurrent quota updates causing 500s.
3
+ *
4
+ * Without atomic writes, two concurrent updateQuotaUsage calls race on
5
+ * .quota.json — the second load can read an empty/partial file and
6
+ * JSON.parse throws "Unexpected end of JSON input".
7
+ */
8
+
9
+ import { describe, it, before, after } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import fs from 'fs-extra';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import {
15
+ initializeQuota,
16
+ updateQuotaUsage,
17
+ loadQuota,
18
+ saveQuota,
19
+ checkQuota,
20
+ calculatePodSize
21
+ } from '../src/storage/quota.js';
22
+
23
+ const POD = 'testpod';
24
+ let TEST_ROOT;
25
+ let originalDataRoot;
26
+
27
+ describe('quota — concurrent updates (#309)', () => {
28
+ before(async () => {
29
+ originalDataRoot = process.env.DATA_ROOT;
30
+ TEST_ROOT = await fs.mkdtemp(path.join(os.tmpdir(), 'jss-quota-'));
31
+ process.env.DATA_ROOT = TEST_ROOT;
32
+ await fs.ensureDir(path.join(TEST_ROOT, POD));
33
+ await initializeQuota(POD, 50 * 1024 * 1024);
34
+ });
35
+
36
+ after(async () => {
37
+ if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
38
+ else process.env.DATA_ROOT = originalDataRoot;
39
+ await fs.remove(TEST_ROOT);
40
+ });
41
+
42
+ it('many concurrent updates do not throw', async () => {
43
+ const N = 50;
44
+ const updates = Array.from({ length: N }, (_, i) =>
45
+ updateQuotaUsage(POD, 10 + i)
46
+ );
47
+ await assert.doesNotReject(Promise.all(updates));
48
+ const final = await loadQuota(POD);
49
+ assert.strictEqual(typeof final.used, 'number');
50
+ assert.ok(final.used > 0);
51
+ });
52
+
53
+ async function withResourceAndBrokenQuota(brokenContent) {
54
+ const resourcePath = path.join(TEST_ROOT, POD, 'resource.txt');
55
+ const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
56
+ const size = 321;
57
+ await fs.writeFile(resourcePath, 'x'.repeat(size));
58
+ await fs.writeFile(quotaPath, brokenContent);
59
+ return { size, cleanup: () => fs.remove(resourcePath) };
60
+ }
61
+
62
+ it('loadQuota reconciles usage from disk when quota file is empty', async () => {
63
+ const { size, cleanup } = await withResourceAndBrokenQuota('');
64
+ try {
65
+ const q = await loadQuota(POD);
66
+ assert.strictEqual(q.limit, 0);
67
+ assert.strictEqual(q.used, size);
68
+ } finally { await cleanup(); }
69
+ });
70
+
71
+ it('loadQuota reconciles usage from disk when quota file is corrupt', async () => {
72
+ const { size, cleanup } = await withResourceAndBrokenQuota('{"limit":524');
73
+ try {
74
+ const q = await loadQuota(POD);
75
+ assert.strictEqual(q.limit, 0);
76
+ assert.strictEqual(q.used, size);
77
+ } finally { await cleanup(); }
78
+ });
79
+
80
+ it('loadQuota sanitizes malformed fields (missing/null/negative)', async () => {
81
+ const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
82
+ const cases = [
83
+ ['{"limit":0}', { limit: 0, used: 0 }],
84
+ ['{"limit":null,"used":null}', { limit: 0, used: 0 }],
85
+ ['{"limit":-10,"used":-5}', { limit: 0, used: 0 }],
86
+ // Numeric strings are coerced — tolerates legacy/manually-edited files.
87
+ ['{"limit":"100","used":"50"}', { limit: 100, used: 50 }]
88
+ ];
89
+ for (const [body, expected] of cases) {
90
+ await fs.writeFile(quotaPath, body);
91
+ const q = await loadQuota(POD);
92
+ assert.deepStrictEqual(q, expected, `for ${body}`);
93
+ }
94
+ });
95
+
96
+ it('calculatePodSize ignores orphaned quota temp files', async () => {
97
+ // Simulate an orphan from a crashed saveQuota (process died between
98
+ // writeFile and rename) — must not be counted toward pod usage.
99
+ const resource = path.join(TEST_ROOT, POD, 'data.txt');
100
+ const orphan = path.join(TEST_ROOT, POD, '.quota.json.tmp.999.1.abc');
101
+ await fs.writeFile(resource, 'x'.repeat(50));
102
+ await fs.writeFile(orphan, 'x'.repeat(9999));
103
+ try {
104
+ const size = await calculatePodSize(POD);
105
+ assert.strictEqual(size, 50, 'orphan temp file must not be counted');
106
+ } finally {
107
+ await fs.remove(resource);
108
+ await fs.remove(orphan);
109
+ }
110
+ });
111
+
112
+ it('checkQuota preserves reconciled usage when re-initializing limit', async () => {
113
+ const { size, cleanup } = await withResourceAndBrokenQuota('');
114
+ try {
115
+ const defaultQuota = 10 * 1024 * 1024;
116
+ const { quota } = await checkQuota(POD, 0, defaultQuota);
117
+ assert.strictEqual(quota.limit, defaultQuota);
118
+ assert.strictEqual(quota.used, size, 'reconciled usage must not be reset to 0');
119
+ } finally { await cleanup(); }
120
+ });
121
+
122
+ it('saveQuota is atomic — concurrent read during save never sees empty file', async () => {
123
+ await saveQuota(POD, { limit: 1000, used: 100 });
124
+ const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
125
+
126
+ // Interleave 200 saves with 200 reads; with non-atomic writes, at least
127
+ // one read would land on a truncated file — the empty-file assertion
128
+ // below (or the JSON.parse) would then fail.
129
+ const ops = [];
130
+ for (let i = 0; i < 200; i++) {
131
+ ops.push(saveQuota(POD, { limit: 1000, used: i }));
132
+ ops.push(fs.readFile(quotaPath, 'utf-8').then((data) => {
133
+ assert.notStrictEqual(
134
+ data.length,
135
+ 0,
136
+ 'concurrent read saw an empty quota file'
137
+ );
138
+ JSON.parse(data);
139
+ }));
140
+ }
141
+ await assert.doesNotReject(Promise.all(ops));
142
+ });
143
+ });