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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.301 - 2026-10-14
|
|
4
|
+
|
|
5
|
+
- Changed: public CI runs its long work in parallel shards (T-0359). 1.36.300's CI run (38332288454) took five hours: each Windows gate job (199-300 min) ran e2e.js after `test:commands`, and the Windows fast job took 46 min (79 min in 1.36.299's run 38193803239).
|
|
6
|
+
- `scripts/ci-shard.js` splits the work. Every shard of a job computes the same split from the same inputs: the units in source order, weighted by measured seconds (`scripts/ci-shard-weights.json`), placed largest first on the least-loaded shard. So the shards of one job run each unit exactly once.
|
|
7
|
+
- e2e.js: each top-level case now starts with `if (e2eShard.take('<key>')) {`, with its `total++;` inside. `LEERNESS_E2E_SHARD=i/k` runs shard i of k; without it every case runs, as before.
|
|
8
|
+
- Cases that mention the shared fixture project or its helpers (`tmp`, `run`, `dsPath`, `dsText`, `secretFile`; 104 of 423, a text match that errs toward grouping) run together in one shard, in file order, because one case leaves state for the next. A first version without this group failed three of them on Linux.
|
|
9
|
+
- The cases start after the line `// e2e-shard: cases start here`. An e2e run fails when a guard is malformed or repeated, a guard is not reached exactly once, or code below that line and outside a case opens a block, a control-flow block or an immediately invoked function, or counts a case. Whatever wraps a case that lost its guard, its `total++` is outside a guard. Each shard still runs the smoke preamble above the line and the run-wide checks at the end (runtime DEP0190 warnings, repository isolation); those checks end the file, and each of their counters is marked `// e2e-shard: run-wide check`.
|
|
10
|
+
- `test:commands` and `test:fast` run as leaves: `node ./scripts/ci-shard.js npm <script> i/k` expands `npm run` steps and runs that shard's `node` leaves in order. It refuses any other step shape and any script with a `pre` or `post` lifecycle script. Unlike an `&&` chain, it runs every leaf of its shard and then fails if any failed.
|
|
11
|
+
- The workflow: e2e in 4 shards on each Windows Node version and 2 on each Ubuntu one; `test:commands` in 2 shards on Windows; the Windows fast job in 3; the other gate steps in one `gate core` job per configuration. The OS/Node configurations and steps are those of 1.36.300.
|
|
12
|
+
- From the per-case times of run 38193803239, an e2e shard should take about 56 min on Windows Node 18 (226 min unsharded), 43-48 min on Node 20-24 and at most 25 min on Ubuntu, plus job setup. This release's CI run is the first measurement.
|
|
13
|
+
- CONTRIBUTING.md describes the case form and how to refresh weights (`LEERNESS_E2E_TIMES`, `LEERNESS_CI_SHARD_TIMES`, `ci-shard.js weights`).
|
|
14
|
+
- Fixed: a command waiting for a workspace migration failed when the migration took longer than the lock wait (T-0348). Such a migration is another command moving a legacy `.harness` workspace to `.leerness`.
|
|
15
|
+
- Measured on this PC: a first migration of 400 files of 8 KiB took 13.1-14.7 s on Windows with Node 20.20.2 and 22.23.3, against 4.7 s on 26.3.0. That is past the 10 s that waiting commands allowed.
|
|
16
|
+
- Most of that time went to reading each fresh copy back to verify it. The first read of a file just written cost about 10 ms on Node 20 and 22 there. Copying took 0.4 s.
|
|
17
|
+
- The migrating process now refreshes the lock file's modification time while it works, at most every 500 ms: in every loop over the migrated files (the walk and its sorting, mapping, collision and destination checks, copies, hashes, comparisons, reference rewrites and rollback). Not covered: one built-in call whose time grows with the file count, such as listing a folder of a million files; at the size of real legacy workspaces such calls take milliseconds. Waiting commands keep waiting while the lock changes. With every wrappable file call of a migration of 60 files slowed by 30 ms, the lock never stays unchanged for 1.5 s; with 15 ms per call on 80 files, the longest unchanged time measured was 588 ms.
|
|
18
|
+
- The list of files to copy no longer searches the already-present files once per file. That search grew with the square of the file count: 11.6 s for 60,000 files on this PC, now 30 ms.
|
|
19
|
+
- `LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS` (default 10 s) is now the longest wait without progress. A lock left by a process that died stops changing, and its waiters still fail after that time. The whole wait is capped at 15 minutes. A positive value below 1.5 s counts as 1.5 s, three refresh intervals, so a short wait does not expire between two refreshes; 0 still does not wait. The refresh interval and the waits are measured on a monotonic clock, so setting the system clock back or forward neither stops the refreshes nor ends a wait.
|
|
20
|
+
- On this PC, `simultaneous-first-migrations-converge-with-lock-wait` in `scripts/workspace-dir-migration-probe.js` now passes; before, it failed here, while the GitHub runner passed it.
|
|
21
|
+
- `docs/runtime-compatibility-api.md` describes the wait.
|
|
22
|
+
- Fixed: `leerness readme sync` counted only column-0 `total++;` lines for a README e2e badge, so a case whose counter sits inside a shard guard was not counted. Projects without guards count as before.
|
|
23
|
+
- Tests (T-0348): `scripts/workspace-migration-heartbeat-probe.js` (`npm run test:migration-heartbeat`, in `test`, `test:core` and `test:fast`) has thirteen cases:
|
|
24
|
+
- every loop of the migration functions must refresh the lock unless it waits for another process's lock or walks a fixed list (a check of the source: the CPU-only loops cannot be slowed from outside);
|
|
25
|
+
- the list of files to copy for 60,000 files must take under 2 s;
|
|
26
|
+
- a waiter set to 100 ms must still outlast a migration slowed to 40 ms per copy, and a positive wait is at least 1.5 s while 0 does not wait (fake clock);
|
|
27
|
+
- the owner's wall clock set back 30 s after its tenth copy must not stop the refreshes, and a waiter's wall clock jumping 60 s forward must not end a 5 s wait;
|
|
28
|
+
- a migration with every wrappable file call slowed (lstat and stat are left alone: the runtime layout refuses a wrapped one): the lock must not stay unchanged for 1.5 s;
|
|
29
|
+
- migrations slowed in one step at a time, 40 ms per copy, per reference rewrite or per destination check, each with another migration waiting 1.5 s for it;
|
|
30
|
+
- a writer waiting 1 s on a lock refreshed for 3.5 s and then released;
|
|
31
|
+
- a lock that never changes, and one that stops changing after 4 s.
|
|
32
|
+
- On the old code it passes only the never-changing case (1 of 13). With the migrating process's refresh turned off it passes 7 of 13: the six cases with a slowed or clock-shifted migration fail. Without the 1.5 s minimum, the two short-wait cases fail; with the wall clock instead of the monotonic one, both clock cases fail in 5 of 5 runs.
|
|
33
|
+
- Tests (T-0359):
|
|
34
|
+
- `scripts/ci-shard-probe.js` (`npm run test:ci-shard`, in `test`, `test:core` and `test:fast`), 13 checks: the split is a deterministic partition for 1-8 shards; every leaf of the sharded npm scripts and every e2e case runs in exactly one shard for 1-6 shards; the shared-fixture cases stay in one shard; the guard parser, the runtime check, the npm step parser, the leaf runner and the workflow's shard lists each catch their broken inputs; `readme sync` counts guarded cases. 24 mutants of the runner and the badge count each fail it.
|
|
35
|
+
- `scripts/release-runtime-probe.js` reads the workflow's shard lists: Windows Node 24 must list every e2e shard. A dropped shard, a mismatched shard count, a missing shard variable and a changed e2e command each fail it.
|
|
36
|
+
- Linux (Docker node:20): HEAD's e2e.js 468/468 and this release's 469/469 print the same result labels apart from the new check. The final version's 3 shards pass (249/150/162), and every case label falls in exactly one shard. The first reviewed version also passed in 3 (259/149/153) and in 2 shards (313/202).
|
|
37
|
+
- Windows (this PC, Node 26.3.0, 4 shards in parallel, first reviewed version): 223/122/136/126, and the labels match the Windows Node 24 e2e step of run 38332288454. Since then e2e.js changed only by comments: the cases-start line and the run-wide marks.
|
|
38
|
+
- Reviewed by codex gpt-6.1-sol and gpt-6-astra over nine rounds; each finding got a test that fails on the reviewed code (rounds 4-9 by owner decision).
|
|
39
|
+
|
|
40
|
+
## 1.36.300 - 2026-10-13
|
|
41
|
+
|
|
42
|
+
- Fixed: `leerness lazy detect --auto-track` registered TODOs that a task already covered (T-0345).
|
|
43
|
+
- When a batch of new TODOs mixed files that no tracker row references with a file a task already referenced, every new TODO got a task, including those in the referenced file.
|
|
44
|
+
- Now only the TODOs whose file no row references are registered, decided under the tracker lock as before, and `known-todos.json` gains exactly those. A TODO in a referenced file is left as it is, as without `--auto-track`.
|
|
45
|
+
- Tests (T-0345, T-0346):
|
|
46
|
+
- `scripts/lazy-auto-track-probe.js` (`npm run test:lazy-auto-track`, in `test:commands`) checks a mixed batch, a rerun, and two controls: nothing referenced, and everything referenced. On the old code the two mixed-batch cases fail: the referenced file's two TODOs got tasks too.
|
|
47
|
+
- The 30 races of `scripts/e2e-concurrency.js` now check that the two commands overlapped. Before, the first command's publication stalled for a fixed 1.2 s, and a second command that started later than that passed without racing.
|
|
48
|
+
- The second command now runs with `scripts/lock-probe.js` too. After each operation on the stalled file it reports: a finished read, open, stat or existence check, and the result of its first attempt at that file's lock or at a lock the first command held when it stalled (refused because held, or taken). It reads those held locks once, when it starts; if they cannot be read, the race does not count. It marks its report complete when it exits. A report that cannot be written sets its exit code to 97 at once, and an incomplete report does not count.
|
|
49
|
+
- The first command stays stalled until a report exists (at most 15 s), then 1.2 s more. `scripts/race-overlap.js` judges the reports. A race counts when such a lock attempt was refused because the first command held the lock. For a command that tried none of those locks, it counts when its read finished before the first command resumed. A first attempt that took such a lock means the first command had already released it, and the race does not count.
|
|
50
|
+
- `scripts/race-overlap-probe.js` (`npm run test:race-overlap`, in `test`, `test:core` and `test:fast`) runs the handshake with two small processes on ten fixed schedules. They include a second process that is slow to reach its lock attempt, one that reads during the stall but takes a lock after the release, held locks that cannot be read, and a report that can no longer be rewritten, also after the report was completed. Write failures are injected (`LOCKPROBE_REPORT_FAIL_AFTER`), so the test does not depend on file permissions. Ablations:
|
|
51
|
+
- every lock attempt reported as refused, whatever its result: 8 of 10;
|
|
52
|
+
- a first process that does not wait for the report: 6 of 10;
|
|
53
|
+
- the held locks read at the first lock attempt, and a failed read kept as "none": 8 of 10;
|
|
54
|
+
- a report judged without the completion mark: 9 of 10;
|
|
55
|
+
- a failed write that sets the exit code only in the closing exit listener: 9 of 10.
|
|
56
|
+
- Measured from the stall to the first report: 468-1,704 ms on this Windows PC and 123-217 ms on Linux (Docker node:20). In `creds-refresh` the second command reached the stalled file 1,704 ms after the stall began, 504 ms after the old fixed stall would have ended, so with the old harness that race would have passed without overlapping.
|
|
57
|
+
- On the current code all 30 races overlapped and passed, on Windows and on Linux. On Linux the second command's lock attempt was refused by the first command's lock in all 30; on Windows in 29, and `bugfix-start-vs-task-add` counted by its read. On Linux with the 1.36.296 code (before T-0343) all 30 overlapped too, and 20 failed: the 19 races T-0343 added and `plan-init-vs-remove`, whose `plan init` T-0343 also fixed. The other 10 pass there, as before.
|
|
58
|
+
|
|
3
59
|
## 1.36.299 - 2026-10-12
|
|
4
60
|
|
|
5
61
|
- Fixed: on Windows, `leerness skill publish` made a `.zip` instead of the `.tgz` when Git for Windows' GNU tar came first on PATH, as it does in Git Bash and in agent shells built on it (T-0352).
|
package/CONTRIBUTING.md
CHANGED
|
@@ -35,6 +35,8 @@ Prefer **behavioral** tests over source-grep guards. A source-grep guard asserts
|
|
|
35
35
|
|
|
36
36
|
When you add a guard, construct the **discriminating case**: an input where the old code passes and only the new check catches it. If you cannot construct one, the check may not be measuring what you think.
|
|
37
37
|
|
|
38
|
+
Each top-level case in `scripts/e2e.js` starts with `if (e2eShard.take('<unique key>')) {` and counts itself with `total++;` inside the block. CI runs that file, `test:commands` and `test:fast` in parallel shards (`scripts/ci-shard.js`): every shard computes the same split from `scripts/ci-shard-weights.json`, so the shards of one job run each case and leaf exactly once. A top-level block without the guard, a repeated key, or a guard that is not reached exactly once fails the e2e run. `node ./scripts/ci-shard.js plan e2e 4` shows the split; a run with `LEERNESS_E2E_TIMES=<file>` (e2e) or `LEERNESS_CI_SHARD_TIMES=<file>` (npm leaves) writes measured seconds, and `node ./scripts/ci-shard.js weights e2e <file>` stores them.
|
|
39
|
+
|
|
38
40
|
## Release
|
|
39
41
|
|
|
40
42
|
`npm test` (gate) → bump `VERSION` in `bin/leerness.js` **and** `version` in `package.json` (selftest asserts they match) → CHANGELOG entry → commit → publish → push.
|
package/README.md
CHANGED
|
@@ -195,7 +195,7 @@ MIT
|
|
|
195
195
|
<!-- leerness:project-readme:start -->
|
|
196
196
|
## Leerness Project Harness
|
|
197
197
|
|
|
198
|
-
이 프로젝트는 Leerness v1.36.
|
|
198
|
+
이 프로젝트는 Leerness v1.36.301 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
199
199
|
|
|
200
200
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
201
201
|
|
|
@@ -249,7 +249,7 @@ leerness memory restore decision <date|title>
|
|
|
249
249
|
|
|
250
250
|
### MCP server (외부 AI 통합)
|
|
251
251
|
|
|
252
|
-
Leerness v1.36.
|
|
252
|
+
Leerness v1.36.301는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
|
|
253
253
|
|
|
254
254
|
```jsonc
|
|
255
255
|
// 카테고리별
|
|
@@ -270,7 +270,7 @@ Leerness v1.36.299는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
270
270
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
271
271
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
272
272
|
|
|
273
|
-
현재 사용 중인 하네스 버전: **1.36.
|
|
273
|
+
현재 사용 중인 하네스 버전: **1.36.301**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
|
|
274
274
|
|
|
275
275
|
### 성능 가이드
|
|
276
276
|
|
|
@@ -308,7 +308,7 @@ leerness release pack --close --auto-main-push
|
|
|
308
308
|
- `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
309
309
|
- `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
310
310
|
|
|
311
|
-
Last synced by Leerness v1.36.
|
|
311
|
+
Last synced by Leerness v1.36.301: 2026-09-30
|
|
312
312
|
<!-- leerness:project-readme:end -->
|
|
313
313
|
GitHub 로그인 계정 확인용 서버 어댑터: [설정·연결·승인과의 구분](docs/github-login.md). 실제 앱 등록/브라우저 세션 연결과 대상별 승인은 후속입니다.
|
|
314
314
|
|
package/bin/leerness.js
CHANGED
|
@@ -115,7 +115,7 @@ const {
|
|
|
115
115
|
migrateLegacyWorkspace,
|
|
116
116
|
} = require('../lib/workspace-dir');
|
|
117
117
|
|
|
118
|
-
const VERSION = '1.36.
|
|
118
|
+
const VERSION = '1.36.301';
|
|
119
119
|
|
|
120
120
|
// MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
|
|
121
121
|
// 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
|
|
@@ -1582,8 +1582,11 @@ function syncReadme(root, versionOverride = null) {
|
|
|
1582
1582
|
const e2ePath = path.join(root, 'scripts', 'e2e.js');
|
|
1583
1583
|
if (exists(e2ePath)) {
|
|
1584
1584
|
// 1.9.271: e2e 테스트 수 = `total++;` 블록 + `run(...)` 호출 (run 헬퍼가 내부에서 total++ 수행) → 실제 결과와 일치.
|
|
1585
|
+
// T-0359: leerness's own e2e.js counts each case inside its CI shard guard (`if (e2eShard.take('<key>')) {`
|
|
1586
|
+
// followed by ` total++;`). Those count too, so guarding the cases does not shrink the estimate.
|
|
1585
1587
|
const body = read(e2ePath);
|
|
1586
|
-
const total = (body.match(/^total\+\+;/gm) || []).length + (body.match(/^run\(/gm) || []).length
|
|
1588
|
+
const total = (body.match(/^total\+\+;/gm) || []).length + (body.match(/^run\(/gm) || []).length
|
|
1589
|
+
+ (body.match(/^if \(e2eShard\.take\('[^'\r\n]+'\)\) \{\r?\n {2}total\+\+;/gm) || []).length;
|
|
1587
1590
|
if (total > 0) {
|
|
1588
1591
|
updated = updated.replace(/badge\/e2e-(\d+)%2F(\d+)-success/g, `badge/e2e-${total}%2F${total}-success`);
|
|
1589
1592
|
}
|
|
@@ -17528,23 +17531,27 @@ function lazyDetect(root, opts = {}) {
|
|
|
17528
17531
|
const header = progressHeader(root, input.text);
|
|
17529
17532
|
const rows2 = readProgressRows(root, input.text);
|
|
17530
17533
|
const lockedText = rows2.map(r => `${r.request || ''} ${r.evidence || ''}`).join('\n');
|
|
17531
|
-
|
|
17534
|
+
// T-0345: only the TODOs whose file no row references. A batch with one untracked file registered every
|
|
17535
|
+
// new TODO, so TODOs in files a task already covered were registered again.
|
|
17536
|
+
const toRegister = newTodos.filter(t => !lockedText.includes(t.file));
|
|
17537
|
+
if (!toRegister.length) return [];
|
|
17532
17538
|
let maxT = _historyRetention.reservedTaskMax(root); const idRe = /\bT-(\d{4,})\b/g;
|
|
17533
17539
|
const scanSrc = (exists(planPath(root)) ? read(planPath(root)) : '') + '\n' + rows2.map(r => r.id).join('\n');
|
|
17534
17540
|
let mm; while ((mm = idRe.exec(scanSrc))) maxT = Math.max(maxT, Number(mm[1]));
|
|
17535
|
-
_historyRetention.assertTaskIdCapacity(maxT,
|
|
17536
|
-
for (const t of
|
|
17541
|
+
_historyRetention.assertTaskIdCapacity(maxT, toRegister.length);
|
|
17542
|
+
for (const t of toRegister) {
|
|
17537
17543
|
maxT++;
|
|
17538
17544
|
rows2.push({ id: `T-${String(maxT).padStart(4, '0')}`, status: 'requested', request: `TODO ${t.file}:${t.line}`, evidence: 'auto-tracked', nextAction: t.text.slice(0, 80), updated: today() });
|
|
17539
17545
|
}
|
|
17540
17546
|
writeProgressRows(root, header, rows2, { input });
|
|
17541
|
-
return
|
|
17547
|
+
return toRegister;
|
|
17542
17548
|
});
|
|
17543
|
-
if (registered) {
|
|
17544
|
-
// known-todos에 추가 — 다음 detect에서 재카운트 안
|
|
17545
|
-
|
|
17549
|
+
if (registered.length) {
|
|
17550
|
+
// known-todos에 추가 — 다음 detect에서 재카운트 안 하도록. T-0345: only what was registered; a TODO in a
|
|
17551
|
+
// file a task already references stays as it is, as without --auto-track.
|
|
17552
|
+
const merged = [...knownList, ...registered.map(t => ({ ...t, ackAt: now() }))];
|
|
17546
17553
|
writeUtf8(knownPath, JSON.stringify(merged, null, 2) + '\n');
|
|
17547
|
-
if (!jsonMode) ok(`${
|
|
17554
|
+
if (!jsonMode) ok(`${registered.length}개 TODO를 progress-tracker에 자동 등록 + known-todos.json 갱신`);
|
|
17548
17555
|
}
|
|
17549
17556
|
} else if (newTodos.length && !jsonMode) {
|
|
17550
17557
|
log(` 💡 자동 등록: leerness lazy detect --auto-track`);
|
|
@@ -96,10 +96,14 @@ New writer operations may read-only wait for an existing regular, single-linked
|
|
|
96
96
|
workspace migration lock before classifying ownership. They reclassify after the peer
|
|
97
97
|
finishes; waiting never grants lock ownership or permission to overwrite a conflict.
|
|
98
98
|
The existing migration wait budget defaults to 10 seconds, is configurable through
|
|
99
|
-
`LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS`, and is clamped to 0–60 seconds.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
`LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS`, and is clamped to 0–60 seconds; a positive value below 1.5 seconds
|
|
100
|
+
counts as 1.5 seconds (three refresh intervals), and 0 does not wait. It is the longest
|
|
101
|
+
wait without progress: the migrating process refreshes the lock file's modification time while
|
|
102
|
+
it works (at most every 500 ms), and a waiter keeps waiting while the lock's size or modification
|
|
103
|
+
time changes, up to 15 minutes in all. A lock that does not change for the budget, such as one
|
|
104
|
+
left by a process that died, and non-regular/linked lock observations block with
|
|
105
|
+
`workspace_dir_migration_locked`; no lock is read, reclaimed or deleted by admission. The budget
|
|
106
|
+
bounds contention waiting, not unrelated Git/filesystem I/O. Public `state compatibility` remains immediate.
|
|
103
107
|
An observed non-workspace denial is terminal even if a peer subsequently removes its
|
|
104
108
|
descriptor. A conflict snapshot gets at most one extra classification when no lock is
|
|
105
109
|
seen, covering a peer that completed between observations without accepting persistent
|
package/lib/runtime-layout.js
CHANGED
|
@@ -388,13 +388,17 @@ function createRuntimeWriterAdmission(root = process.cwd(), lockWaitMs) {
|
|
|
388
388
|
const configured = lockWaitMs === undefined ? envValue('LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS') : lockWaitMs;
|
|
389
389
|
const parsed = configured === '' || configured === undefined ? NaN : Number(configured);
|
|
390
390
|
const budget = Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
|
|
391
|
-
|
|
391
|
+
// T-0348: the budget is the longest wait without progress: the migrating process refreshes the lock file's
|
|
392
|
+
// modification time as it works (lib/workspace-dir.js), and a lock that stops changing fails after it.
|
|
393
|
+
const lockWait = W.migrationLockWait(lock, budget, W.MIGRATION_LOCK_MAX_WAIT_MS, clock);
|
|
394
|
+
let lastLockStat = null;
|
|
392
395
|
const contention = () => {
|
|
393
396
|
waitedForLock = true;
|
|
394
|
-
if (
|
|
397
|
+
if (lockWait.expired(lastLockStat)) fail('workspace_dir_migration_locked');
|
|
395
398
|
};
|
|
396
399
|
const pending = () => {
|
|
397
400
|
const stat = statMaybe(lock);
|
|
401
|
+
lastLockStat = stat;
|
|
398
402
|
if (!stat) return false;
|
|
399
403
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) fail('workspace_dir_migration_locked');
|
|
400
404
|
return true;
|
|
@@ -403,7 +407,7 @@ function createRuntimeWriterAdmission(root = process.cwd(), lockWaitMs) {
|
|
|
403
407
|
for (;;) {
|
|
404
408
|
while (pending()) {
|
|
405
409
|
contention();
|
|
406
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, Math.min(25,
|
|
410
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, Math.min(25, lockWait.remainingMs())));
|
|
407
411
|
}
|
|
408
412
|
const reader = createRuntimeCompatibilityReader(location.root);
|
|
409
413
|
const report = reader();
|
package/lib/workspace-dir.js
CHANGED
|
@@ -82,13 +82,15 @@ function _inspectDir(root, name) {
|
|
|
82
82
|
try { entries = fs.readdirSync(abs); }
|
|
83
83
|
catch (error) { inspectionError = error.code || 'unknown'; }
|
|
84
84
|
}
|
|
85
|
-
|
|
85
|
+
// T-0348 review round 8: a migration inspects the workspace again after taking its lock, so these per-entry
|
|
86
|
+
// filters refresh the lock too (a no-op outside a migration).
|
|
87
|
+
const markers = entries.filter((entry) => { _beat(); return STRONG_MARKERS.has(entry); });
|
|
86
88
|
const live = markers.length > 0;
|
|
87
89
|
const substrateEntries = name === CANONICAL_WORKSPACE_DIR
|
|
88
|
-
? entries.filter((entry) => SUBSTRATE_TOP_LEVEL.has(entry))
|
|
90
|
+
? entries.filter((entry) => { _beat(); return SUBSTRATE_TOP_LEVEL.has(entry); })
|
|
89
91
|
: [];
|
|
90
92
|
const nonSubstrateEntries = name === CANONICAL_WORKSPACE_DIR
|
|
91
|
-
? entries.filter((entry) => !SUBSTRATE_TOP_LEVEL.has(entry))
|
|
93
|
+
? entries.filter((entry) => { _beat(); return !SUBSTRATE_TOP_LEVEL.has(entry); })
|
|
92
94
|
: entries.slice();
|
|
93
95
|
const substrateOnly = name === CANONICAL_WORKSPACE_DIR
|
|
94
96
|
&& isDirectory && !live && substrateEntries.length > 0 && nonSubstrateEntries.length === 0;
|
|
@@ -205,9 +207,11 @@ function legacyWorkspacePath(root, ...parts) {
|
|
|
205
207
|
}
|
|
206
208
|
|
|
207
209
|
function _hashFile(file) {
|
|
210
|
+
_beat();
|
|
208
211
|
const h = crypto.createHash('sha256');
|
|
209
212
|
let body;
|
|
210
213
|
for (let attempt = 0; ; attempt++) {
|
|
214
|
+
_beat();
|
|
211
215
|
try { body = fs.readFileSync(file); break; }
|
|
212
216
|
catch (error) {
|
|
213
217
|
const transient = error && ['EBUSY', 'EPERM', 'EACCES'].includes(error.code);
|
|
@@ -222,6 +226,7 @@ function _hashFile(file) {
|
|
|
222
226
|
function _walkFiles(base) {
|
|
223
227
|
const out = [];
|
|
224
228
|
function visit(rel) {
|
|
229
|
+
_beat();
|
|
225
230
|
const abs = rel ? path.join(base, rel) : base;
|
|
226
231
|
const stat = fs.lstatSync(abs);
|
|
227
232
|
if (stat.isSymbolicLink()) {
|
|
@@ -232,7 +237,10 @@ function _walkFiles(base) {
|
|
|
232
237
|
);
|
|
233
238
|
}
|
|
234
239
|
if (stat.isDirectory()) {
|
|
235
|
-
|
|
240
|
+
// Review round 7: sorting a huge folder is CPU work before the first visit; the comparator refreshes the lock.
|
|
241
|
+
// `a < b` on strings compares UTF-16 code units, the order of the default sort.
|
|
242
|
+
const entries = fs.readdirSync(abs).sort((a, b) => { _beat(); return a < b ? -1 : a > b ? 1 : 0; });
|
|
243
|
+
for (const entry of entries) visit(rel ? path.join(rel, entry) : entry);
|
|
236
244
|
return;
|
|
237
245
|
}
|
|
238
246
|
if (stat.isFile()) out.push({ rel, abs, size: stat.size, hash: _hashFile(abs) });
|
|
@@ -253,6 +261,7 @@ function _mappedRelative(rel) {
|
|
|
253
261
|
}
|
|
254
262
|
|
|
255
263
|
function _copyFile(src, dst) {
|
|
264
|
+
_beat();
|
|
256
265
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
257
266
|
fs.copyFileSync(src, dst);
|
|
258
267
|
}
|
|
@@ -285,6 +294,8 @@ function _safeRemoveCanonicalDir(root, target) {
|
|
|
285
294
|
}
|
|
286
295
|
|
|
287
296
|
function _rewriteLegacyReference(file) {
|
|
297
|
+
// T-0348 review round 1: the rewrite pass after the copy is long for a large workspace; it refreshes the lock too.
|
|
298
|
+
_beat();
|
|
288
299
|
let raw;
|
|
289
300
|
try { raw = fs.readFileSync(file); } catch { return false; }
|
|
290
301
|
if (raw.length > 4 * 1024 * 1024 || raw.includes(0)) return false;
|
|
@@ -363,18 +374,21 @@ function _isTransientLockFsError(error) {
|
|
|
363
374
|
}
|
|
364
375
|
|
|
365
376
|
function _removeOwnedLockFile(lockFile) {
|
|
366
|
-
const deadline =
|
|
377
|
+
const deadline = _monotonicMs() + 2000;
|
|
367
378
|
while (true) {
|
|
368
379
|
try { fs.unlinkSync(lockFile); return; }
|
|
369
380
|
catch (error) {
|
|
370
381
|
if (error && error.code === 'ENOENT') return;
|
|
371
|
-
if (!_isTransientLockFsError(error) ||
|
|
372
|
-
_sleepSync(Math.min(25, Math.max(1, deadline -
|
|
382
|
+
if (!_isTransientLockFsError(error) || _monotonicMs() >= deadline) return;
|
|
383
|
+
_sleepSync(Math.min(25, Math.max(1, deadline - _monotonicMs())));
|
|
373
384
|
}
|
|
374
385
|
}
|
|
375
386
|
}
|
|
376
387
|
|
|
377
388
|
function _destinationSnapshot(file) {
|
|
389
|
+
// T-0348 review round 2: the check before the copy finds most destinations missing and hashes nothing, so it
|
|
390
|
+
// refreshes the lock itself.
|
|
391
|
+
_beat();
|
|
378
392
|
if (!fs.existsSync(file)) return { kind: 'missing', hash: null };
|
|
379
393
|
const stat = fs.lstatSync(file);
|
|
380
394
|
if (stat.isSymbolicLink()) {
|
|
@@ -384,6 +398,20 @@ function _destinationSnapshot(file) {
|
|
|
384
398
|
return { kind: 'file', hash: _hashFile(file) };
|
|
385
399
|
}
|
|
386
400
|
|
|
401
|
+
// The destinations to copy: every mapped file except those already identical there. T-0348 review round 3: this used
|
|
402
|
+
// to search the skipped list once per file, quadratic in the file count (60,000 skipped files took 33 s in the
|
|
403
|
+
// reviewer's reproduction, past a waiter's 10 s); a Set keeps it linear, and each file refreshes the lock.
|
|
404
|
+
function _copiedFiles(mapped, skippedFiles) {
|
|
405
|
+
const skipped = new Set(skippedFiles);
|
|
406
|
+
const out = [];
|
|
407
|
+
for (const item of mapped) {
|
|
408
|
+
_beat();
|
|
409
|
+
const rel = item.mappedRel.replace(/\\/g, '/');
|
|
410
|
+
if (!skipped.has(rel)) out.push(rel);
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
387
415
|
function _sameDestinationSnapshot(a, b) {
|
|
388
416
|
return !!a && !!b && a.kind === b.kind && a.hash === b.hash;
|
|
389
417
|
}
|
|
@@ -395,6 +423,58 @@ function _migrationLockWaitMs(opts = {}) {
|
|
|
395
423
|
return Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
|
|
396
424
|
}
|
|
397
425
|
|
|
426
|
+
// T-0348: a first migration can take longer than the lock wait. On Windows with Node 20 and 22, 400 files took
|
|
427
|
+
// 11-15 s here, mostly reading each fresh copy back to verify it. The commands waiting for it then failed while it
|
|
428
|
+
// was still working. So the owner refreshes the lock file's modification time while it works, at most every
|
|
429
|
+
// 500 ms, from each step of its walk, checks, copies, hashes, rewrites and rollback. A waiter keeps waiting while that
|
|
430
|
+
// time moves. The lock wait (LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS, default 10 s, at least 1.5 s when set) is now
|
|
431
|
+
// the longest wait without progress; a lock left by a process that died stops moving, and its waiters still fail
|
|
432
|
+
// after it. The whole wait is capped at 15 min.
|
|
433
|
+
const MIGRATION_LOCK_MAX_WAIT_MS = 15 * 60 * 1000;
|
|
434
|
+
const _MIGRATION_HEARTBEAT_MS = 500;
|
|
435
|
+
// Review round 5: elapsed times use a monotonic clock (as the runtime-layout admission does). With the wall clock, a
|
|
436
|
+
// clock set back stopped the refreshes until it caught up, and one set forward ended a wait at once.
|
|
437
|
+
const _monotonicMs = () => Number(process.hrtime.bigint() / 1000000n);
|
|
438
|
+
let _migrationHeartbeat = null;
|
|
439
|
+
function _beat() { if (_migrationHeartbeat) _migrationHeartbeat(); }
|
|
440
|
+
function _startMigrationHeartbeat(lockFd) {
|
|
441
|
+
let last = -Infinity;
|
|
442
|
+
_migrationHeartbeat = () => {
|
|
443
|
+
const now = _monotonicMs();
|
|
444
|
+
if (now - last < _MIGRATION_HEARTBEAT_MS) return;
|
|
445
|
+
last = now;
|
|
446
|
+
// The stored time only has to change; it is the wall clock, whatever it says.
|
|
447
|
+
try { const at = new Date(); fs.futimesSync(lockFd, at, at); } catch {}
|
|
448
|
+
};
|
|
449
|
+
_migrationHeartbeat();
|
|
450
|
+
}
|
|
451
|
+
function _stopMigrationHeartbeat() { _migrationHeartbeat = null; }
|
|
452
|
+
|
|
453
|
+
// The wait of a process that found the migration lock taken. `expired()` observes the lock once and is true when
|
|
454
|
+
// its size and modification time (or its absence) have not changed for `idleMs`, or `capMs` passed since the wait
|
|
455
|
+
// began. A caller that has just read the lock's bigint lstat passes it (or null when the lock was missing), so the
|
|
456
|
+
// lock is not read twice. `remainingMs()` is the time until it would expire without a further change.
|
|
457
|
+
// Review round 4: the owner refreshes at most every 500 ms, so a shorter wait could expire between two refreshes of
|
|
458
|
+
// a migration that is progressing. A positive wait is therefore at least three refresh intervals; 0 still means
|
|
459
|
+
// "do not wait".
|
|
460
|
+
const MIGRATION_LOCK_MIN_WAIT_MS = 3 * _MIGRATION_HEARTBEAT_MS;
|
|
461
|
+
function migrationLockWait(lockFile, waitMs, capMs = MIGRATION_LOCK_MAX_WAIT_MS, clock = _monotonicMs) {
|
|
462
|
+
const idleMs = waitMs > 0 ? Math.max(waitMs, MIGRATION_LOCK_MIN_WAIT_MS) : 0;
|
|
463
|
+
const startedAt = clock();
|
|
464
|
+
let lastSignature, lastChangeAt = startedAt;
|
|
465
|
+
const signatureOf = st => (st ? `${st.size}:${st.mtimeNs}` : 'absent');
|
|
466
|
+
const signature = () => { try { return signatureOf(fs.lstatSync(lockFile, { bigint: true })); } catch { return 'absent'; } };
|
|
467
|
+
return {
|
|
468
|
+
startedAt,
|
|
469
|
+
expired(observed) {
|
|
470
|
+
const now = clock(), current = observed === undefined ? signature() : signatureOf(observed);
|
|
471
|
+
if (current !== lastSignature) { lastSignature = current; lastChangeAt = now; }
|
|
472
|
+
return now - lastChangeAt >= idleMs || now - startedAt >= capMs;
|
|
473
|
+
},
|
|
474
|
+
remainingMs() { return Math.max(0, Math.min(lastChangeAt + idleMs, startedAt + capMs) - clock()); },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
398
478
|
function _newMigrationReport(state, opts = {}) {
|
|
399
479
|
const at = opts.at || new Date().toISOString();
|
|
400
480
|
const version = String(opts.version || 'unknown');
|
|
@@ -436,8 +516,8 @@ function migrateLegacyWorkspace(root, opts = {}) {
|
|
|
436
516
|
|
|
437
517
|
const lockWaitMs = _migrationLockWaitMs(opts);
|
|
438
518
|
const lockFile = path.join(initial.root, '.leerness-workspace-migration.lock');
|
|
439
|
-
const startedAt =
|
|
440
|
-
const
|
|
519
|
+
const startedAt = _monotonicMs();
|
|
520
|
+
const lockWait = migrationLockWait(lockFile, lockWaitMs);
|
|
441
521
|
let lockFd = null;
|
|
442
522
|
let waited = false;
|
|
443
523
|
|
|
@@ -450,31 +530,33 @@ function migrateLegacyWorkspace(root, opts = {}) {
|
|
|
450
530
|
report.blockedReason = 'workspace-dir-migration-failed';
|
|
451
531
|
report.errors.push(error && error.message ? error.message : String(error));
|
|
452
532
|
report.waitedForLock = waited;
|
|
453
|
-
report.lockWaitedMs = waited ? Math.max(0,
|
|
533
|
+
report.lockWaitedMs = waited ? Math.max(0, _monotonicMs() - startedAt) : 0;
|
|
454
534
|
return report;
|
|
455
535
|
}
|
|
456
536
|
waited = true;
|
|
457
|
-
if (
|
|
537
|
+
if (lockWait.expired()) {
|
|
458
538
|
const report = _newMigrationReport(initial, opts);
|
|
459
539
|
report.blocked = true;
|
|
460
540
|
report.blockedReason = 'workspace-dir-migration-locked';
|
|
461
541
|
report.errors.push(`another workspace migration still owns: ${lockFile}`);
|
|
462
542
|
report.waitedForLock = true;
|
|
463
|
-
report.lockWaitedMs = Math.max(0,
|
|
543
|
+
report.lockWaitedMs = Math.max(0, _monotonicMs() - startedAt);
|
|
464
544
|
return report;
|
|
465
545
|
}
|
|
466
|
-
_sleepSync(Math.min(50, Math.max(1,
|
|
546
|
+
_sleepSync(Math.min(50, Math.max(1, lockWait.remainingMs())));
|
|
467
547
|
}
|
|
468
548
|
}
|
|
469
549
|
|
|
550
|
+
_startMigrationHeartbeat(lockFd);
|
|
470
551
|
try {
|
|
471
552
|
const report = _migrateLegacyWorkspace(initial.root, { ...opts, [_MIGRATION_LOCK_OWNED]: true });
|
|
472
553
|
if (waited) {
|
|
473
554
|
report.waitedForLock = true;
|
|
474
|
-
report.lockWaitedMs = Math.max(report.lockWaitedMs || 0,
|
|
555
|
+
report.lockWaitedMs = Math.max(report.lockWaitedMs || 0, _monotonicMs() - startedAt);
|
|
475
556
|
}
|
|
476
557
|
return report;
|
|
477
558
|
} finally {
|
|
559
|
+
_stopMigrationHeartbeat();
|
|
478
560
|
try { fs.closeSync(lockFd); } catch {}
|
|
479
561
|
_removeOwnedLockFile(lockFile);
|
|
480
562
|
}
|
|
@@ -504,8 +586,8 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
504
586
|
// applying any conflict verdict; otherwise simultaneous first handoffs race
|
|
505
587
|
// against an intermediate state and some fail before reaching the lock.
|
|
506
588
|
if (!dryRun && !migrationLockOwned && fs.existsSync(lockFile)) {
|
|
507
|
-
const peerStartedAt =
|
|
508
|
-
const
|
|
589
|
+
const peerStartedAt = _monotonicMs();
|
|
590
|
+
const peerWait = migrationLockWait(lockFile, lockWaitMs);
|
|
509
591
|
report.waitedForLock = true;
|
|
510
592
|
while (fs.existsSync(lockFile)) {
|
|
511
593
|
const latest = inspectWorkspace(state.root);
|
|
@@ -515,18 +597,21 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
515
597
|
report.alreadyCanonical = true;
|
|
516
598
|
report.backupDir = fs.existsSync(path.join(state.root, LEGACY_BACKUP_WORKSPACE_DIR))
|
|
517
599
|
? path.join(state.root, LEGACY_BACKUP_WORKSPACE_DIR) : null;
|
|
518
|
-
report.lockWaitedMs = Math.max(0,
|
|
600
|
+
report.lockWaitedMs = Math.max(0, _monotonicMs() - peerStartedAt);
|
|
519
601
|
return report;
|
|
520
602
|
}
|
|
521
|
-
if (
|
|
522
|
-
report.lockWaitedMs = Math.max(0,
|
|
603
|
+
if (peerWait.expired()) {
|
|
604
|
+
report.lockWaitedMs = Math.max(0, _monotonicMs() - peerStartedAt);
|
|
523
605
|
return block('workspace-dir-migration-locked', `another workspace migration still owns: ${lockFile}`);
|
|
524
606
|
}
|
|
525
|
-
_sleepSync(Math.min(50, Math.max(1,
|
|
607
|
+
_sleepSync(Math.min(50, Math.max(1, peerWait.remainingMs())));
|
|
526
608
|
}
|
|
527
609
|
// The peer released without completing (normally after rollback). Start a
|
|
528
610
|
// fresh inspection instead of trusting the intermediate snapshot above.
|
|
529
|
-
|
|
611
|
+
// T-0348: what is left of the wait, at most the full wait without progress; observing the release first counts
|
|
612
|
+
// it as progress, while the 15-minute cap still applies.
|
|
613
|
+
peerWait.expired();
|
|
614
|
+
return migrateLegacyWorkspace(state.root, { ...opts, lockWaitMs: Math.min(lockWaitMs, peerWait.remainingMs()) });
|
|
530
615
|
}
|
|
531
616
|
|
|
532
617
|
// The initial inspection and the lock existence check are not one atomic
|
|
@@ -582,9 +667,13 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
582
667
|
return block(error.code === 'E_WORKSPACE_DIR_SYMLINK' ? 'workspace-dir-symlink' : 'workspace-dir-read-failed', error.message);
|
|
583
668
|
}
|
|
584
669
|
|
|
585
|
-
|
|
670
|
+
// T-0348 review round 6: every loop over the migrated files refreshes the lock, the CPU-only ones too (mapping
|
|
671
|
+
// and the collision check took 3.2 s for 120,000 long paths in the reviewer's reproduction). The heartbeat
|
|
672
|
+
// probe checks that each per-file loop of this function calls _beat().
|
|
673
|
+
const mapped = sourceFiles.map((item) => { _beat(); return { ...item, mappedRel: _mappedRelative(item.rel) }; });
|
|
586
674
|
const mappedDestinations = new Map();
|
|
587
675
|
for (const item of mapped) {
|
|
676
|
+
_beat();
|
|
588
677
|
const rel = item.mappedRel.replace(/\\/g, '/');
|
|
589
678
|
const key = process.platform === 'win32' ? rel.toLowerCase() : rel;
|
|
590
679
|
if (mappedDestinations.has(key)) {
|
|
@@ -595,6 +684,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
595
684
|
mappedDestinations.set(key, item.rel.replace(/\\/g, '/'));
|
|
596
685
|
}
|
|
597
686
|
for (const item of mapped) {
|
|
687
|
+
_beat();
|
|
598
688
|
if (item.mappedRel !== item.rel) report.mappedFiles.push({ from: item.rel.replace(/\\/g, '/'), to: item.mappedRel.replace(/\\/g, '/') });
|
|
599
689
|
const dst = path.join(state.canonical.abs, item.mappedRel);
|
|
600
690
|
let snapshot;
|
|
@@ -615,6 +705,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
615
705
|
else report.conflicts.push(item.mappedRel.replace(/\\/g, '/'));
|
|
616
706
|
}
|
|
617
707
|
const unsafeTypeConflict = mapped.some((item) => {
|
|
708
|
+
_beat();
|
|
618
709
|
const dst = path.join(state.canonical.abs, item.mappedRel);
|
|
619
710
|
if (!fs.existsSync(dst)) return false;
|
|
620
711
|
try {
|
|
@@ -630,9 +721,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
630
721
|
`destination has ${report.conflicts.length} conflicting file(s)`);
|
|
631
722
|
}
|
|
632
723
|
|
|
633
|
-
report.copiedFiles = mapped
|
|
634
|
-
.filter((item) => !report.skippedFiles.includes(item.mappedRel.replace(/\\/g, '/')))
|
|
635
|
-
.map((item) => item.mappedRel.replace(/\\/g, '/'));
|
|
724
|
+
report.copiedFiles = _copiedFiles(mapped, report.skippedFiles);
|
|
636
725
|
report.overwrittenFiles = report.conflicts.slice();
|
|
637
726
|
if (dryRun) return report;
|
|
638
727
|
|
|
@@ -645,8 +734,8 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
645
734
|
const canonicalExisted = state.canonical.exists;
|
|
646
735
|
let legacyRenamed = false;
|
|
647
736
|
try {
|
|
648
|
-
const
|
|
649
|
-
const
|
|
737
|
+
const lockStartedAt = _monotonicMs();
|
|
738
|
+
const lockWait = lockFd === null ? migrationLockWait(lockFile, lockWaitMs) : null;
|
|
650
739
|
while (lockFd === null) {
|
|
651
740
|
try { lockFd = fs.openSync(lockFile, 'wx'); }
|
|
652
741
|
catch (error) {
|
|
@@ -661,16 +750,17 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
661
750
|
report.dstExists = true;
|
|
662
751
|
report.alreadyCanonical = true;
|
|
663
752
|
report.backupDir = fs.existsSync(sourceBackup) ? sourceBackup : null;
|
|
664
|
-
report.lockWaitedMs = Math.max(0,
|
|
753
|
+
report.lockWaitedMs = Math.max(0, _monotonicMs() - lockStartedAt);
|
|
665
754
|
return report;
|
|
666
755
|
}
|
|
667
|
-
if (
|
|
756
|
+
if (lockWait.expired()) {
|
|
668
757
|
throw new WorkspaceDirectoryError('E_WORKSPACE_DIR_LOCKED', `another workspace migration still owns: ${lockFile}`);
|
|
669
758
|
}
|
|
670
|
-
_sleepSync(Math.min(50, Math.max(1,
|
|
759
|
+
_sleepSync(Math.min(50, Math.max(1, lockWait.remainingMs())));
|
|
671
760
|
}
|
|
672
761
|
}
|
|
673
|
-
if (
|
|
762
|
+
if (!migrationLockOwned) _startMigrationHeartbeat(lockFd);
|
|
763
|
+
if (report.waitedForLock) report.lockWaitedMs = Math.max(0, _monotonicMs() - lockStartedAt);
|
|
674
764
|
// The previous owner can finish between our last EEXIST observation and
|
|
675
765
|
// this successful open. Re-inspect under ownership before applying the
|
|
676
766
|
// pre-lock source/backup snapshot.
|
|
@@ -693,6 +783,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
693
783
|
fs.mkdirSync(migrationRoot, { recursive: true });
|
|
694
784
|
|
|
695
785
|
for (const item of mapped) {
|
|
786
|
+
_beat();
|
|
696
787
|
const dst = path.join(state.canonical.abs, item.mappedRel);
|
|
697
788
|
const currentSnapshot = _destinationSnapshot(dst);
|
|
698
789
|
if (!_sameDestinationSnapshot(item.destinationSnapshot, currentSnapshot)) {
|
|
@@ -713,14 +804,15 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
713
804
|
|
|
714
805
|
// A writer that ignored the migration lock must not be silently discarded.
|
|
715
806
|
const after = _walkFiles(state.legacy.abs);
|
|
716
|
-
const beforeSig = sourceFiles.map((x) => `${x.rel}\0${x.hash}
|
|
717
|
-
const afterSig = after.map((x) => `${x.rel}\0${x.hash}
|
|
807
|
+
const beforeSig = sourceFiles.map((x) => { _beat(); return `${x.rel}\0${x.hash}`; }).join('\n');
|
|
808
|
+
const afterSig = after.map((x) => { _beat(); return `${x.rel}\0${x.hash}`; }).join('\n');
|
|
718
809
|
if (beforeSig !== afterSig) throw new WorkspaceDirectoryError('E_WORKSPACE_DIR_CHANGED', `${LEGACY_WORKSPACE_DIR} changed during migration`);
|
|
719
810
|
|
|
720
811
|
// Rewrite only files that are part of the migrated workspace plus a small,
|
|
721
812
|
// explicit set of leerness integration files at the project root. The
|
|
722
813
|
// source backup remains byte-for-byte legacy data for recovery.
|
|
723
814
|
for (const item of mapped) {
|
|
815
|
+
_beat();
|
|
724
816
|
const dst = path.join(state.canonical.abs, item.mappedRel);
|
|
725
817
|
if (_rewriteLegacyReference(dst)) report.rewrittenReferences.push(path.relative(state.root, dst).replace(/\\/g, '/'));
|
|
726
818
|
}
|
|
@@ -822,8 +914,9 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
822
914
|
fs.renameSync(sourceBackup, state.legacy.abs);
|
|
823
915
|
legacyRenamed = false;
|
|
824
916
|
}
|
|
825
|
-
for (const item of overwritten.reverse()) _copyFile(item.backup, item.dst);
|
|
826
|
-
for (const file of created.reverse()) if (fs.existsSync(file)) fs.rmSync(file, { force: true });
|
|
917
|
+
for (const item of overwritten.reverse()) { _beat(); _copyFile(item.backup, item.dst); }
|
|
918
|
+
for (const file of created.reverse()) { _beat(); if (fs.existsSync(file)) fs.rmSync(file, { force: true }); }
|
|
919
|
+
_beat();
|
|
827
920
|
if (!canonicalExisted && fs.existsSync(state.canonical.abs)) _safeRemoveCanonicalDir(state.root, state.canonical.abs);
|
|
828
921
|
} catch (rollbackError) {
|
|
829
922
|
report.errors.push(`rollback failed: ${rollbackError.message}`);
|
|
@@ -831,6 +924,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
831
924
|
return report;
|
|
832
925
|
} finally {
|
|
833
926
|
if (!migrationLockOwned && lockFd !== null) {
|
|
927
|
+
_stopMigrationHeartbeat();
|
|
834
928
|
try { fs.closeSync(lockFd); } catch {}
|
|
835
929
|
_removeOwnedLockFile(lockFile);
|
|
836
930
|
}
|
|
@@ -838,6 +932,11 @@ function _migrateLegacyWorkspace(root, opts = {}) {
|
|
|
838
932
|
}
|
|
839
933
|
|
|
840
934
|
module.exports = {
|
|
935
|
+
// T-0348: the progress-based wait for the migration lock, shared with lib/runtime-layout.js.
|
|
936
|
+
migrationLockWait,
|
|
937
|
+
MIGRATION_LOCK_MAX_WAIT_MS,
|
|
938
|
+
MIGRATION_LOCK_MIN_WAIT_MS,
|
|
939
|
+
_copiedFiles,
|
|
841
940
|
CANONICAL_WORKSPACE_DIR,
|
|
842
941
|
LEGACY_WORKSPACE_DIR,
|
|
843
942
|
LEGACY_BACKUP_WORKSPACE_DIR,
|