leerness 1.36.299 → 1.36.301
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/CHANGELOG.md +56 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +4 -4
- package/bin/leerness.js +17 -10
- package/docs/runtime-compatibility-api.md +8 -4
- package/lib/runtime-layout.js +7 -3
- package/lib/workspace-dir.js +134 -35
- package/package.json +9 -5
- package/scripts/ci-shard-probe.js +343 -0
- package/scripts/ci-shard-weights.json +590 -0
- package/scripts/ci-shard.js +394 -0
- package/scripts/e2e-child-diagnostics-probe.js +5 -3
- package/scripts/e2e-concurrency.js +24 -10
- package/scripts/e2e.js +843 -822
- package/scripts/lazy-auto-track-probe.js +87 -0
- package/scripts/lock-probe.js +117 -10
- package/scripts/race-overlap-probe.js +148 -0
- package/scripts/race-overlap.js +28 -0
- package/scripts/release-runtime-probe.js +9 -6
- package/scripts/workspace-migration-heartbeat-probe.js +334 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T-0345: `lazy detect --auto-track` registers a task for each new TODO whose file no tracker row references, and
|
|
3
|
+
// only those. A batch that mixed such files with a file a task already referenced registered every new TODO, so the
|
|
4
|
+
// TODOs in the referenced file got tasks of their own as well. known-todos.json gains exactly the registered TODOs.
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const cp = require('child_process');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
|
|
12
|
+
const ENV = { ...process.env, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_LOCK_WARN: '1', LEERNESS_NO_AUTOCHCP: '1' };
|
|
13
|
+
const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lazy-auto-track-'));
|
|
14
|
+
let passed = 0, failed = 0, seq = 0;
|
|
15
|
+
function test(name, fn) {
|
|
16
|
+
try { fn(); passed++; console.log('PASS ' + name); }
|
|
17
|
+
catch (error) { failed++; console.error('FAIL ' + name + ': ' + (error && error.stack || error)); }
|
|
18
|
+
}
|
|
19
|
+
const run = (d, args) => {
|
|
20
|
+
const r = cp.spawnSync(process.execPath, [CLI, ...args], { cwd: d, env: ENV, encoding: 'utf8', timeout: 120000, windowsHide: true });
|
|
21
|
+
assert.strictEqual(r.error, undefined, r.error && r.error.message);
|
|
22
|
+
return r;
|
|
23
|
+
};
|
|
24
|
+
// A project with the given source files and, for each entry of `referenced`, a task whose text names that file.
|
|
25
|
+
function project(files, referenced = []) {
|
|
26
|
+
const d = path.join(arena, 'p' + (++seq));
|
|
27
|
+
fs.mkdirSync(d);
|
|
28
|
+
assert.strictEqual(run(d, ['init', d, '--yes', '--minimal']).status, 0, 'init');
|
|
29
|
+
for (const [name, text] of Object.entries(files)) {
|
|
30
|
+
fs.mkdirSync(path.dirname(path.join(d, name)), { recursive: true });
|
|
31
|
+
fs.writeFileSync(path.join(d, name), text);
|
|
32
|
+
}
|
|
33
|
+
for (const name of referenced) assert.strictEqual(run(d, ['task', 'add', `Clean up ${name}`, '--path', d]).status, 0, 'task add');
|
|
34
|
+
return d;
|
|
35
|
+
}
|
|
36
|
+
const tasks = d => {
|
|
37
|
+
const r = run(d, ['task', 'list', '--path', d, '--json']);
|
|
38
|
+
assert.strictEqual(r.status, 0, r.stdout + r.stderr);
|
|
39
|
+
const parsed = JSON.parse(r.stdout);
|
|
40
|
+
return (parsed.tasks || parsed).map(t => t.request);
|
|
41
|
+
};
|
|
42
|
+
const autoTracked = d => tasks(d).filter(request => /^TODO /.test(request)).sort();
|
|
43
|
+
const known = d => {
|
|
44
|
+
const file = path.join(d, '.leerness', 'known-todos.json');
|
|
45
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')).map(t => `${t.file}:${t.line}`).sort() : null;
|
|
46
|
+
};
|
|
47
|
+
const autoTrack = d => {
|
|
48
|
+
const r = run(d, ['lazy', 'detect', d, '--auto-track']);
|
|
49
|
+
assert.ok(r.status === 0 || r.status === 1, 'lazy detect exit ' + r.status + '\n' + r.stdout + r.stderr);
|
|
50
|
+
return r;
|
|
51
|
+
};
|
|
52
|
+
const SOURCES = {
|
|
53
|
+
'src/covered.js': '// TODO: first covered note\nfunction a() {}\n// FIXME: second covered note\n',
|
|
54
|
+
'src/fresh.js': 'function b() {}\n// TODO: fresh note\n',
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
test('a mixed batch registers only the TODO in the file no task references', () => {
|
|
58
|
+
const d = project(SOURCES, ['src/covered.js']);
|
|
59
|
+
autoTrack(d);
|
|
60
|
+
assert.deepStrictEqual(autoTracked(d), ['TODO src/fresh.js:2']);
|
|
61
|
+
assert.deepStrictEqual(known(d), ['src/fresh.js:2']);
|
|
62
|
+
assert.strictEqual(tasks(d).filter(request => request === 'Clean up src/covered.js').length, 1, 'the referencing task is kept as it was');
|
|
63
|
+
});
|
|
64
|
+
test('running it again registers nothing more', () => {
|
|
65
|
+
const d = project(SOURCES, ['src/covered.js']);
|
|
66
|
+
autoTrack(d);
|
|
67
|
+
autoTrack(d);
|
|
68
|
+
assert.deepStrictEqual(autoTracked(d), ['TODO src/fresh.js:2']);
|
|
69
|
+
assert.deepStrictEqual(known(d), ['src/fresh.js:2']);
|
|
70
|
+
});
|
|
71
|
+
test('control: with no file referenced, every new TODO is registered', () => {
|
|
72
|
+
const d = project(SOURCES);
|
|
73
|
+
autoTrack(d);
|
|
74
|
+
assert.deepStrictEqual(autoTracked(d), ['TODO src/covered.js:1', 'TODO src/covered.js:3', 'TODO src/fresh.js:2']);
|
|
75
|
+
assert.deepStrictEqual(known(d), ['src/covered.js:1', 'src/covered.js:3', 'src/fresh.js:2']);
|
|
76
|
+
});
|
|
77
|
+
test('control: with every file referenced, nothing is registered and known-todos.json is not written', () => {
|
|
78
|
+
const d = project(SOURCES, ['src/covered.js', 'src/fresh.js']);
|
|
79
|
+
autoTrack(d);
|
|
80
|
+
assert.deepStrictEqual(autoTracked(d), []);
|
|
81
|
+
assert.strictEqual(known(d), null);
|
|
82
|
+
});
|
|
83
|
+
} finally {
|
|
84
|
+
fs.rmSync(arena, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
console.log(`lazy auto-track probe: ${passed}/${passed + failed} passed (${process.platform})`);
|
|
87
|
+
process.exitCode = failed ? 1 : 0;
|
package/scripts/lock-probe.js
CHANGED
|
@@ -35,6 +35,25 @@ const STALL_ABSOLUTE = process.env.LOCKPROBE_STALL_TARGET && path.isAbsolute(pro
|
|
|
35
35
|
const STALL_PREFIX = String(process.env.LOCKPROBE_STALL_PREFIX || '').replace(/\\/g, '/').replace(/^\.?\/+/, '');
|
|
36
36
|
const STALL_MS = Math.max(0, Math.min(10000, Number(process.env.LOCKPROBE_STALL_MS) || 0));
|
|
37
37
|
const READY_FILE = process.env.LOCKPROBE_READY_FILE ? path.resolve(process.env.LOCKPROBE_READY_FILE) : null;
|
|
38
|
+
// T-0346: a race's second command reports what it did at the stalled target (LOCKPROBE_REACH_TARGET) in
|
|
39
|
+
// LOCKPROBE_REACH_FILE, each report written after the operation ran: a read, open, stat or existence check of the
|
|
40
|
+
// target, and the result of its first attempt at the target's lock or at a lock the first command held when it
|
|
41
|
+
// stalled (EEXIST: contended; success: acquired, so not contended). The first command, given that file as
|
|
42
|
+
// LOCKPROBE_WAIT_FILE, keeps its publication stalled until a report exists (at most LOCKPROBE_WAIT_MAX_MS) and
|
|
43
|
+
// then for LOCKPROBE_STALL_MS more. A fixed stall alone could end before the second command had started, and a
|
|
44
|
+
// report written before the operation (review round 1) could be followed by the operation after the stall ended;
|
|
45
|
+
// either way the race passed without overlapping anything. scripts/race-overlap.js judges the reports.
|
|
46
|
+
const targetMatcher = raw => {
|
|
47
|
+
const value = String(raw || '');
|
|
48
|
+
if (!value) return () => false;
|
|
49
|
+
if (path.isAbsolute(value)) { const full = norm(value).replace(/\\/g, '/'); return p => norm(p).replace(/\\/g, '/') === full; }
|
|
50
|
+
const rel = value.replace(/\\/g, '/').replace(/^\.?\/+/, '');
|
|
51
|
+
return p => relOf(norm(p)) === rel;
|
|
52
|
+
};
|
|
53
|
+
const REACH_FILE = process.env.LOCKPROBE_REACH_FILE ? path.resolve(process.env.LOCKPROBE_REACH_FILE) : null;
|
|
54
|
+
const isReachTarget = REACH_FILE ? targetMatcher(process.env.LOCKPROBE_REACH_TARGET) : () => false;
|
|
55
|
+
const WAIT_FILE = process.env.LOCKPROBE_WAIT_FILE ? path.resolve(process.env.LOCKPROBE_WAIT_FILE) : null;
|
|
56
|
+
const WAIT_MAX_MS = Math.max(0, Math.min(60000, Number(process.env.LOCKPROBE_WAIT_MAX_MS) || 15000));
|
|
38
57
|
let stalled = false;
|
|
39
58
|
const shouldStall = p => {
|
|
40
59
|
if (stalled || STALL_MS <= 0) return false;
|
|
@@ -48,11 +67,89 @@ const stall = ms => {
|
|
|
48
67
|
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
49
68
|
catch { const until = Date.now() + ms; while (Date.now() < until) {} }
|
|
50
69
|
};
|
|
70
|
+
const _existsRaw = fs.existsSync.bind(fs);
|
|
71
|
+
const _readRaw = fs.readFileSync.bind(fs);
|
|
72
|
+
// A lock the first command held when it stalled (`heldOn` of its ready file, LOCKPROBE_REACH_LOCKS_FILE) counts too:
|
|
73
|
+
// a second command that waits for such a lock is held up by the stalled section, even when it waits there before
|
|
74
|
+
// touching the target, or never touches it (a plan add waiting for the plan lock while a restore publishes the
|
|
75
|
+
// plan archive).
|
|
76
|
+
const REACH_LOCKS_FILE = process.env.LOCKPROBE_REACH_LOCKS_FILE ? path.resolve(process.env.LOCKPROBE_REACH_LOCKS_FILE) : null;
|
|
77
|
+
// Read once, when this preload starts. The race starts the second command only after it read the first command's
|
|
78
|
+
// whole ready file, and the first command rewrites that file only after a report exists, so no rewrite is under
|
|
79
|
+
// way now. Review round 2: the set was read lazily, and a read during a rewrite was cached as "holds no lock", which
|
|
80
|
+
// hid a later lock taken after the release. A file that does not parse within 2 s marks every report
|
|
81
|
+
// `heldUnknown`, and such a race does not count.
|
|
82
|
+
let firstHeld = new Set(), firstHeldKnown = !REACH_LOCKS_FILE;
|
|
83
|
+
if (REACH_LOCKS_FILE && REACH_FILE) {
|
|
84
|
+
const until = Date.now() + 2000;
|
|
85
|
+
for (;;) {
|
|
86
|
+
try { const heldOn = JSON.parse(_readRaw(REACH_LOCKS_FILE, 'utf8')).heldOn; if (Array.isArray(heldOn)) { firstHeld = new Set(heldOn); firstHeldKnown = true; break; } } catch {}
|
|
87
|
+
if (Date.now() >= until) break;
|
|
88
|
+
stall(10);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const heldByFirst = p => firstHeld.has(relOf(norm(p)));
|
|
92
|
+
// The report holds the first completed access of the target (`read`: how and when) and the result of the first
|
|
93
|
+
// attempt at one of those locks (`lock`: contended or acquired, and when). It is rewritten when either is first set.
|
|
94
|
+
const report = firstHeldKnown ? {} : { heldUnknown: true };
|
|
95
|
+
// Review round 3: a rewrite that failed (EACCES, say) left the earlier report standing, and its read then counted
|
|
96
|
+
// although a later lock was taken after the release. So the report is trusted only with `complete: true`, written
|
|
97
|
+
// when this process exits; a failed write marks it `failed` where it can, and the process exits with 97.
|
|
98
|
+
let reportFailed = false;
|
|
99
|
+
// LOCKPROBE_REPORT_FAIL_AFTER=n (tests of this module only): writes after the n-th fail as a read-only file would,
|
|
100
|
+
// and the report cannot be removed either, the same for every user (a chmod does not stop root).
|
|
101
|
+
const FAIL_AFTER = process.env.LOCKPROBE_REPORT_FAIL_AFTER === undefined ? Infinity : Number(process.env.LOCKPROBE_REPORT_FAIL_AFTER);
|
|
102
|
+
let writes = 0;
|
|
103
|
+
const writeReport = () => {
|
|
104
|
+
try {
|
|
105
|
+
if (++writes > FAIL_AFTER) throw Object.assign(new Error('injected report write failure'), { code: 'EACCES' });
|
|
106
|
+
fs.writeFileSync.call(fs, REACH_FILE, JSON.stringify(report), 'utf8');
|
|
107
|
+
return true;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
reportFailed = true;
|
|
110
|
+
// Review round 4: fail at once, not only in the closing exit listener: a listener that runs after it could take
|
|
111
|
+
// a lock whose report then fails to land. The stale report is also removed where possible.
|
|
112
|
+
process.exitCode = 97;
|
|
113
|
+
if (writes <= FAIL_AFTER) { try { fs.unlinkSync.call(fs, REACH_FILE); } catch {} }
|
|
114
|
+
try { process.stderr.write(`lock-probe: the race report could not be written (${error && error.code || error})\n`); } catch {}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
if (REACH_FILE) {
|
|
119
|
+
process.on('exit', () => {
|
|
120
|
+
if (!report.read && !report.lock && !reportFailed) return;
|
|
121
|
+
if (reportFailed) report.failed = true; else report.complete = true;
|
|
122
|
+
if (!writeReport() || report.failed) process.exitCode = 97;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const isWatchedLock = p => { const lock = String(p); return /\.lock$/i.test(lock) && (isReachTarget(lock.slice(0, -'.lock'.length)) || heldByFirst(lock)); };
|
|
126
|
+
// After a read-type call on the target ran (also when it threw, as for a file missing during a POSIX publication).
|
|
127
|
+
const reachedRead = (p, how) => {
|
|
128
|
+
if (!REACH_FILE || report.read) return;
|
|
129
|
+
try { if (isReachTarget(String(p))) { report.read = { target: relOf(norm(p)), how, at: Date.now() }; writeReport(); } } catch {}
|
|
130
|
+
};
|
|
131
|
+
// After a lock attempt ran: `error` is what it threw, or null when it took the lock.
|
|
132
|
+
const reachedLock = (p, error) => {
|
|
133
|
+
if (!REACH_FILE || report.lock) return;
|
|
134
|
+
try {
|
|
135
|
+
if (!isWatchedLock(p)) return;
|
|
136
|
+
if (error && error.code !== 'EEXIST') return;
|
|
137
|
+
report.lock = { target: relOf(norm(p)), result: error ? 'contended' : 'acquired', at: Date.now() };
|
|
138
|
+
writeReport();
|
|
139
|
+
} catch {}
|
|
140
|
+
};
|
|
141
|
+
for (const [name, how] of [['readFileSync', 'read'], ['existsSync', 'exists'], ['statSync', 'stat'], ['lstatSync', 'stat'], ['accessSync', 'stat']]) {
|
|
142
|
+
const original = fs[name].bind(fs);
|
|
143
|
+
fs[name] = function (p, ...a) { try { return original(p, ...a); } finally { reachedRead(p, how); } };
|
|
144
|
+
}
|
|
51
145
|
|
|
52
146
|
const _mkdir = fs.mkdirSync.bind(fs);
|
|
53
147
|
fs.mkdirSync = function (p, ...a) {
|
|
54
|
-
const
|
|
55
|
-
|
|
148
|
+
const isLock = /\.lock$/.test(String(p));
|
|
149
|
+
let r;
|
|
150
|
+
try { r = _mkdir(p, ...a); } catch (error) { if (isLock) reachedLock(p, error); throw error; }
|
|
151
|
+
if (isLock) reachedLock(p, null);
|
|
152
|
+
try { if (isLock) held.add(norm(p)); } catch {}
|
|
56
153
|
return r;
|
|
57
154
|
};
|
|
58
155
|
const _rmdir = fs.rmdirSync.bind(fs);
|
|
@@ -64,8 +161,12 @@ fs.rmdirSync = function (p, ...a) {
|
|
|
64
161
|
// 구버전 고정 파일 락도 계측 호환성을 유지한다.
|
|
65
162
|
const _open = fs.openSync.bind(fs);
|
|
66
163
|
fs.openSync = function (p, flags, ...a) {
|
|
67
|
-
const
|
|
68
|
-
|
|
164
|
+
const isLock = String(flags) === 'wx' && /\.lock$/.test(String(p));
|
|
165
|
+
let r;
|
|
166
|
+
try { r = _open(p, flags, ...a); }
|
|
167
|
+
catch (error) { if (isLock) reachedLock(p, error); else reachedRead(p, 'open'); throw error; }
|
|
168
|
+
if (isLock) reachedLock(p, null); else reachedRead(p, 'open');
|
|
169
|
+
try { if (isLock) held.add(norm(p)); } catch {}
|
|
69
170
|
return r;
|
|
70
171
|
};
|
|
71
172
|
const _unlink = fs.unlinkSync.bind(fs);
|
|
@@ -91,14 +192,20 @@ const stallBefore = (to) => {
|
|
|
91
192
|
if (shouldStall(to)) {
|
|
92
193
|
stalled = true;
|
|
93
194
|
const target = norm(to);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
195
|
+
const ready = { target: relOf(target), locked: held.has(target + '.lock'), heldOn: [...held].map(relOf).sort() };
|
|
196
|
+
if (READY_FILE) _w(READY_FILE, JSON.stringify(ready), 'utf8');
|
|
197
|
+
if (WAIT_FILE) {
|
|
198
|
+
// T-0346: hold the publication until the second command has reached the target, then STALL_MS more. The
|
|
199
|
+
// outcome is added to the ready file, which the race reads after both commands exit.
|
|
200
|
+
const started = Date.now();
|
|
201
|
+
while (!_existsRaw(WAIT_FILE) && Date.now() - started < WAIT_MAX_MS) stall(10);
|
|
202
|
+
ready.reached = _existsRaw(WAIT_FILE);
|
|
203
|
+
ready.waitedMs = Date.now() - started;
|
|
204
|
+
if (READY_FILE) _w(READY_FILE, JSON.stringify(ready), 'utf8');
|
|
100
205
|
}
|
|
101
206
|
stall(STALL_MS);
|
|
207
|
+
// When the publication goes on; a read reported after this moment did not see the stalled state.
|
|
208
|
+
if (WAIT_FILE) { ready.resumedAt = Date.now(); if (READY_FILE) _w(READY_FILE, JSON.stringify(ready), 'utf8'); }
|
|
102
209
|
}
|
|
103
210
|
} catch {}
|
|
104
211
|
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T-0346: the e2e-concurrency handshake (scripts/lock-probe.js reports, scripts/race-overlap.js judgement) with two
|
|
3
|
+
// small processes instead of leerness commands, so each schedule is fixed. The first process takes
|
|
4
|
+
// .leerness/x.json.lock (and y.json.lock when asked), prepares x.json and renames it into place; the preload stalls
|
|
5
|
+
// that rename. The second process does what each case says. A race may count only when the second process really
|
|
6
|
+
// met the stalled section.
|
|
7
|
+
const assert = require('assert');
|
|
8
|
+
const cp = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { overlapOf } = require('./race-overlap');
|
|
13
|
+
|
|
14
|
+
const PRELOAD = path.join(__dirname, 'lock-probe.js');
|
|
15
|
+
const STALL_MS = 1200;
|
|
16
|
+
const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-race-overlap-'));
|
|
17
|
+
let passed = 0, failed = 0, seq = 0;
|
|
18
|
+
function test(name, fn) {
|
|
19
|
+
return fn().then(() => { passed++; console.log('PASS ' + name); },
|
|
20
|
+
error => { failed++; console.error('FAIL ' + name + ': ' + (error && error.stack || error)); });
|
|
21
|
+
}
|
|
22
|
+
const wait = child => new Promise(resolve => child.on('close', code => resolve(code)));
|
|
23
|
+
const nap = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
24
|
+
const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
25
|
+
// Shared by both processes: `sleep(ms)` and `takeLock(name)` (retry on EEXIST, as leerness locks do). The first
|
|
26
|
+
// process holds x.json.lock and, when asked, y.json.lock, a lock the race knows only from the first one's `heldOn`.
|
|
27
|
+
const PRELUDE = `
|
|
28
|
+
const fs = require('fs'), path = require('path');
|
|
29
|
+
const hd = path.join(process.argv[1], '.leerness'), target = path.join(hd, 'x.json'), lock = target + '.lock';
|
|
30
|
+
const other = path.join(hd, 'y.json.lock');
|
|
31
|
+
const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
32
|
+
const takeLock = (name = lock) => { for (;;) { try { fs.mkdirSync(name); return; } catch (e) { if (e.code !== 'EEXIST') throw e; sleep(20); } } };
|
|
33
|
+
`;
|
|
34
|
+
const FIRST = PRELUDE + `
|
|
35
|
+
const both = process.argv[2] === 'both';
|
|
36
|
+
takeLock(); if (both) takeLock(other);
|
|
37
|
+
const tmp = target + '.tmp-1-1';
|
|
38
|
+
fs.writeFileSync(tmp, JSON.stringify({ by: 'first' }));
|
|
39
|
+
fs.renameSync(tmp, target);
|
|
40
|
+
if (both) fs.rmdirSync(other);
|
|
41
|
+
fs.rmdirSync(lock);
|
|
42
|
+
`;
|
|
43
|
+
// `heldFile`: what the second process reads as the first one's held locks: 'ready' (the ready file itself),
|
|
44
|
+
// 'corrupt-after-read' (a copy that the race overwrites with half a JSON object once the second process has
|
|
45
|
+
// reported its read, as a rewrite caught midway would look), or 'corrupt' (half a JSON object from the start).
|
|
46
|
+
// `failAfter`: report writes after that many fail in the second process (LOCKPROBE_REPORT_FAIL_AFTER), as for a
|
|
47
|
+
// report file that turned read-only, whoever runs the test.
|
|
48
|
+
async function race(second, { waitMaxMs = 15000, firstLocks = 'one', heldFile = 'ready', failAfter } = {}) {
|
|
49
|
+
const d = path.join(arena, 'case-' + (++seq));
|
|
50
|
+
fs.mkdirSync(path.join(d, '.leerness'), { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(d, '.leerness', 'x.json'), JSON.stringify({ by: 'seed' }));
|
|
52
|
+
const ready = path.join(d, 'ready.json'), reach = path.join(d, 'reach.json'), heldCopy = path.join(d, 'held.json');
|
|
53
|
+
const first = cp.spawn(process.execPath, ['--require', PRELOAD, '-e', FIRST, d, firstLocks], { stdio: 'ignore', env: { ...process.env,
|
|
54
|
+
LOCKPROBE_STALL_TARGET: 'x.json', LOCKPROBE_STALL_MS: String(STALL_MS), LOCKPROBE_READY_FILE: ready,
|
|
55
|
+
LOCKPROBE_WAIT_FILE: reach, LOCKPROBE_WAIT_MAX_MS: String(waitMaxMs) } });
|
|
56
|
+
let stalled = false;
|
|
57
|
+
for (let i = 0; i < 800 && !stalled; i++) { stalled = (readJson(ready) || {}).target === 'x.json'; if (!stalled) nap(10); }
|
|
58
|
+
const half = '{"target":"x.json","heldOn":["x.js';
|
|
59
|
+
if (heldFile !== 'ready') fs.writeFileSync(heldCopy, heldFile === 'corrupt' ? half : fs.readFileSync(ready));
|
|
60
|
+
const started = Date.now();
|
|
61
|
+
const child = cp.spawn(process.execPath, ['--require', PRELOAD, '-e', PRELUDE + second, d], { stdio: 'ignore', env: { ...process.env,
|
|
62
|
+
LOCKPROBE_REACH_TARGET: 'x.json', LOCKPROBE_REACH_FILE: reach, LOCKPROBE_REACH_LOCKS_FILE: heldFile === 'ready' ? ready : heldCopy,
|
|
63
|
+
...(failAfter === undefined ? {} : { LOCKPROBE_REPORT_FAIL_AFTER: String(failAfter) }) } });
|
|
64
|
+
if (heldFile === 'corrupt-after-read') {
|
|
65
|
+
for (let i = 0; i < 1500 && !(readJson(reach) || {}).read; i++) nap(10);
|
|
66
|
+
fs.writeFileSync(heldCopy, half);
|
|
67
|
+
}
|
|
68
|
+
const exits = await Promise.all([wait(first), wait(child)]);
|
|
69
|
+
const r = { stalled, ready: readJson(ready), reach: readJson(reach), exits, secondStartedAt: started };
|
|
70
|
+
return { ...r, ...overlapOf(r) };
|
|
71
|
+
}
|
|
72
|
+
(async () => {
|
|
73
|
+
try {
|
|
74
|
+
await test('a lock attempt that waits on the stalled section counts', async () => {
|
|
75
|
+
const r = await race('takeLock(); fs.rmdirSync(lock);');
|
|
76
|
+
assert.deepStrictEqual(r.exits, [0, 0]);
|
|
77
|
+
assert.strictEqual(r.reach.lock.result, 'contended', JSON.stringify(r.reach));
|
|
78
|
+
assert.strictEqual(r.overlapped, true, r.why);
|
|
79
|
+
});
|
|
80
|
+
await test('a delay before the first access does not let the stall end first', async () => {
|
|
81
|
+
// The schedule of review round 1: the second process is slow to reach its lock attempt. The report is written
|
|
82
|
+
// after the attempt ran, so the first process is still stalled when it happens.
|
|
83
|
+
const r = await race('sleep(1500); takeLock(); fs.rmdirSync(lock);');
|
|
84
|
+
assert.strictEqual(r.reach.lock.result, 'contended', JSON.stringify(r.reach));
|
|
85
|
+
assert.ok(r.ready.waitedMs >= 1400, 'the first process waited for the report: ' + r.ready.waitedMs);
|
|
86
|
+
assert.ok(r.reach.lock.at <= r.ready.resumedAt, 'the attempt came before the first process resumed');
|
|
87
|
+
assert.strictEqual(r.overlapped, true, r.why);
|
|
88
|
+
});
|
|
89
|
+
await test('a read during the stall followed by a lock taken after the release does not count', async () => {
|
|
90
|
+
const r = await race(`fs.existsSync(target); sleep(${STALL_MS + 1300}); takeLock(); fs.rmdirSync(lock);`);
|
|
91
|
+
assert.strictEqual(r.reach.read.how, 'exists');
|
|
92
|
+
assert.strictEqual(r.reach.lock.result, 'acquired', JSON.stringify(r.reach));
|
|
93
|
+
assert.strictEqual(r.overlapped, false, r.why);
|
|
94
|
+
});
|
|
95
|
+
await test('a command that only reads counts when its read finished while stalled', async () => {
|
|
96
|
+
const r = await race('fs.readFileSync(target);');
|
|
97
|
+
assert.ok(['read', 'open'].includes(r.reach.read.how), JSON.stringify(r.reach)); // readFileSync opens through openSync
|
|
98
|
+
assert.strictEqual(r.reach.lock, undefined);
|
|
99
|
+
assert.ok(r.reach.read.at <= r.ready.resumedAt);
|
|
100
|
+
assert.strictEqual(r.overlapped, true, r.why);
|
|
101
|
+
});
|
|
102
|
+
await test("a lock attempt that waits on another lock the first process holds counts", async () => {
|
|
103
|
+
const r = await race('takeLock(other); fs.rmdirSync(other);', { firstLocks: 'both' });
|
|
104
|
+
assert.strictEqual(r.reach.lock.target, 'y.json.lock', JSON.stringify(r.reach));
|
|
105
|
+
assert.strictEqual(r.reach.lock.result, 'contended');
|
|
106
|
+
assert.strictEqual(r.overlapped, true, r.why);
|
|
107
|
+
});
|
|
108
|
+
await test('review round 2: a held-locks file broken after the read does not hide a lock taken after the release', async () => {
|
|
109
|
+
// The second process read the held locks when it started; a rewrite caught midway later must not change them.
|
|
110
|
+
const r = await race(`fs.existsSync(target); sleep(${STALL_MS + 1300}); takeLock(other); fs.rmdirSync(other);`,
|
|
111
|
+
{ firstLocks: 'both', heldFile: 'corrupt-after-read' });
|
|
112
|
+
assert.strictEqual(r.reach.lock && r.reach.lock.result, 'acquired', JSON.stringify(r.reach));
|
|
113
|
+
assert.strictEqual(r.overlapped, false, r.why);
|
|
114
|
+
});
|
|
115
|
+
await test('review round 3: a report that can no longer be rewritten does not let an earlier read count', async () => {
|
|
116
|
+
// The read report lands; the later "acquired" cannot be written (the first write succeeds, later ones fail).
|
|
117
|
+
const r = await race(`fs.existsSync(target); sleep(${STALL_MS + 1300}); takeLock(other); fs.rmdirSync(other);`,
|
|
118
|
+
{ firstLocks: 'both', failAfter: 1 });
|
|
119
|
+
assert.ok(r.reach && r.reach.read && !r.reach.lock && r.reach.complete === undefined, JSON.stringify(r.reach));
|
|
120
|
+
assert.strictEqual(r.exits[1], 97, 'the second process fails when its report cannot be written');
|
|
121
|
+
assert.strictEqual(r.overlapped, false, r.why);
|
|
122
|
+
});
|
|
123
|
+
await test('review round 4: a lock taken in a later exit listener whose report fails makes the second process fail', async () => {
|
|
124
|
+
// The preload's closing listener marks the read report complete (write 2); a listener registered after it then
|
|
125
|
+
// takes a held lock after the release, and that report (write 3) fails. The completed read report stays, so
|
|
126
|
+
// only the exit code can tell the race.
|
|
127
|
+
const r = await race(`fs.existsSync(target); process.on('exit', () => { sleep(${STALL_MS + 1300}); takeLock(other); fs.rmdirSync(other); });`,
|
|
128
|
+
{ firstLocks: 'both', failAfter: 2 });
|
|
129
|
+
assert.ok(r.reach && r.reach.read && r.reach.complete === true && !r.reach.lock, JSON.stringify(r.reach));
|
|
130
|
+
assert.strictEqual(r.exits[1], 97, 'the failed write after completion still fails the process');
|
|
131
|
+
});
|
|
132
|
+
await test('held locks that cannot be read make the race not count', async () => {
|
|
133
|
+
const r = await race('takeLock(); fs.rmdirSync(lock);', { heldFile: 'corrupt' });
|
|
134
|
+
assert.strictEqual(r.reach.heldUnknown, true, JSON.stringify(r.reach));
|
|
135
|
+
assert.strictEqual(r.overlapped, false, r.why);
|
|
136
|
+
});
|
|
137
|
+
await test('a command that never reaches the stalled section does not count', async () => {
|
|
138
|
+
const r = await race("fs.readFileSync(path.join(process.argv[1], 'ready.json'));", { waitMaxMs: 1500 });
|
|
139
|
+
assert.strictEqual(r.ready.reached, false);
|
|
140
|
+
assert.strictEqual(r.reach, null);
|
|
141
|
+
assert.strictEqual(r.overlapped, false, r.why);
|
|
142
|
+
});
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(arena, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
console.log(`race overlap probe: ${passed}/${passed + failed} passed (${process.platform})`);
|
|
147
|
+
process.exitCode = failed ? 1 : 0;
|
|
148
|
+
})();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T-0346: did a race's second command overlap the first command's stalled publication? Judged from the first
|
|
3
|
+
// command's ready file (scripts/lock-probe.js: `reached`, `resumedAt`) and the second command's report (`read`,
|
|
4
|
+
// `lock`), which lock-probe.js writes only after the operation ran.
|
|
5
|
+
// - A lock attempt that failed with EEXIST (the stalled file's lock, or a lock the first command held when it
|
|
6
|
+
// stalled) shows that the second command waited on the stalled section.
|
|
7
|
+
// - A first such attempt that took the lock shows that the first command had already released it: the second
|
|
8
|
+
// command's locked section ran after the publication, so it did not overlap.
|
|
9
|
+
// - A command that never tried one of those locks overlapped when its read of the stalled file finished before the
|
|
10
|
+
// first command resumed. The first command waits for that report, so this holds unless its wait timed out.
|
|
11
|
+
function overlapOf({ stalled, ready, reach }) {
|
|
12
|
+
if (!stalled || !ready || ready.reached !== true || !reach || (!reach.read && !reach.lock)) {
|
|
13
|
+
return { overlapped: false, why: 'no report while the first command was stalled' };
|
|
14
|
+
}
|
|
15
|
+
// Without the first command's held locks, a lock taken after the release could go unreported.
|
|
16
|
+
if (reach.heldUnknown) return { overlapped: false, why: "the first command's held locks could not be read" };
|
|
17
|
+
// Only a report that the second command closed at its exit is whole: a later rewrite may have failed.
|
|
18
|
+
if (reach.complete !== true || reach.failed) return { overlapped: false, why: 'the report was not completed' };
|
|
19
|
+
if (reach.lock) {
|
|
20
|
+
return reach.lock.result === 'contended'
|
|
21
|
+
? { overlapped: true, why: 'a lock attempt was contended' }
|
|
22
|
+
: { overlapped: false, why: 'a lock the first command held was taken without contention' };
|
|
23
|
+
}
|
|
24
|
+
if (typeof ready.resumedAt === 'number' && reach.read.at <= ready.resumedAt) return { overlapped: true, why: 'the read finished while stalled' };
|
|
25
|
+
return { overlapped: false, why: 'the read finished after the first command resumed' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { overlapOf };
|
|
@@ -1075,12 +1075,15 @@ try {
|
|
|
1075
1075
|
|| !/name:\s*release runtime \(shell-free npm\)/.test(workflow)) {
|
|
1076
1076
|
failures.push('CI가 전체 게이트와 Node 24/26 매트릭스에서 릴리스 런타임 프로브를 실행하지 않음');
|
|
1077
1077
|
}
|
|
1078
|
-
|
|
1079
|
-
const
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1078
|
+
// T-0359: the full E2E runs in shards; Windows Node 24 must list every shard (1..k) of the e2e job.
|
|
1079
|
+
const ciJobs = require('./ci-shard').workflowJobs(workflow);
|
|
1080
|
+
const e2eJob = ciJobs.test || { entries: [], runs: [], lines: [] };
|
|
1081
|
+
const win24 = e2eJob.entries.filter(e => e.os === 'windows-latest' && e.node === '24');
|
|
1082
|
+
const win24Shards = win24.map(e => Number(e.shard)).sort((a, b) => a - b);
|
|
1083
|
+
if (!win24.length || win24.some(e => e.shards !== win24[0].shards) || win24Shards.length !== Number(win24[0].shards)
|
|
1084
|
+
|| win24Shards.some((s, i) => s !== i + 1)
|
|
1085
|
+
|| !e2eJob.runs.includes('node ./scripts/e2e.js')
|
|
1086
|
+
|| !e2eJob.lines.some(l => /^\s+LEERNESS_E2E_SHARD: \$\{\{ matrix\.shard \}\}\/\$\{\{ matrix\.shards \}\}$/.test(l))) {
|
|
1084
1087
|
failures.push('Windows Node 24 전체 E2E가 CI test 매트릭스에서 실행되지 않음');
|
|
1085
1088
|
}
|
|
1086
1089
|
|