leerness 1.36.299 → 1.36.300
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 +19 -0
- package/README.md +4 -4
- package/bin/leerness.js +13 -9
- package/package.json +7 -5
- package/scripts/e2e-concurrency.js +24 -10
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.300 - 2026-10-13
|
|
4
|
+
|
|
5
|
+
- Fixed: `leerness lazy detect --auto-track` registered TODOs that a task already covered (T-0345).
|
|
6
|
+
- 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.
|
|
7
|
+
- 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`.
|
|
8
|
+
- Tests (T-0345, T-0346):
|
|
9
|
+
- `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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- `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:
|
|
14
|
+
- every lock attempt reported as refused, whatever its result: 8 of 10;
|
|
15
|
+
- a first process that does not wait for the report: 6 of 10;
|
|
16
|
+
- the held locks read at the first lock attempt, and a failed read kept as "none": 8 of 10;
|
|
17
|
+
- a report judged without the completion mark: 9 of 10;
|
|
18
|
+
- a failed write that sets the exit code only in the closing exit listener: 9 of 10.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
3
22
|
## 1.36.299 - 2026-10-12
|
|
4
23
|
|
|
5
24
|
- 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/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.300 하네스를 사용합니다. 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.300는 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.300**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
|
|
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.300: 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.300';
|
|
119
119
|
|
|
120
120
|
// MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
|
|
121
121
|
// 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
|
|
@@ -17528,23 +17528,27 @@ function lazyDetect(root, opts = {}) {
|
|
|
17528
17528
|
const header = progressHeader(root, input.text);
|
|
17529
17529
|
const rows2 = readProgressRows(root, input.text);
|
|
17530
17530
|
const lockedText = rows2.map(r => `${r.request || ''} ${r.evidence || ''}`).join('\n');
|
|
17531
|
-
|
|
17531
|
+
// T-0345: only the TODOs whose file no row references. A batch with one untracked file registered every
|
|
17532
|
+
// new TODO, so TODOs in files a task already covered were registered again.
|
|
17533
|
+
const toRegister = newTodos.filter(t => !lockedText.includes(t.file));
|
|
17534
|
+
if (!toRegister.length) return [];
|
|
17532
17535
|
let maxT = _historyRetention.reservedTaskMax(root); const idRe = /\bT-(\d{4,})\b/g;
|
|
17533
17536
|
const scanSrc = (exists(planPath(root)) ? read(planPath(root)) : '') + '\n' + rows2.map(r => r.id).join('\n');
|
|
17534
17537
|
let mm; while ((mm = idRe.exec(scanSrc))) maxT = Math.max(maxT, Number(mm[1]));
|
|
17535
|
-
_historyRetention.assertTaskIdCapacity(maxT,
|
|
17536
|
-
for (const t of
|
|
17538
|
+
_historyRetention.assertTaskIdCapacity(maxT, toRegister.length);
|
|
17539
|
+
for (const t of toRegister) {
|
|
17537
17540
|
maxT++;
|
|
17538
17541
|
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
17542
|
}
|
|
17540
17543
|
writeProgressRows(root, header, rows2, { input });
|
|
17541
|
-
return
|
|
17544
|
+
return toRegister;
|
|
17542
17545
|
});
|
|
17543
|
-
if (registered) {
|
|
17544
|
-
// known-todos에 추가 — 다음 detect에서 재카운트 안
|
|
17545
|
-
|
|
17546
|
+
if (registered.length) {
|
|
17547
|
+
// known-todos에 추가 — 다음 detect에서 재카운트 안 하도록. T-0345: only what was registered; a TODO in a
|
|
17548
|
+
// file a task already references stays as it is, as without --auto-track.
|
|
17549
|
+
const merged = [...knownList, ...registered.map(t => ({ ...t, ackAt: now() }))];
|
|
17546
17550
|
writeUtf8(knownPath, JSON.stringify(merged, null, 2) + '\n');
|
|
17547
|
-
if (!jsonMode) ok(`${
|
|
17551
|
+
if (!jsonMode) ok(`${registered.length}개 TODO를 progress-tracker에 자동 등록 + known-todos.json 갱신`);
|
|
17548
17552
|
}
|
|
17549
17553
|
} else if (newTodos.length && !jsonMode) {
|
|
17550
17554
|
log(` 💡 자동 등록: leerness lazy detect --auto-track`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.300",
|
|
4
4
|
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"LICENSE"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication && npm run test:ui-review-target && npm run test:ui-review-authorization && npm run test:ui-review-authority-host && npm run test:ui-review-authority-floor && npm run test:ui-review-proof-use && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:agent-chat-stream && npm run test:encoding-diagnostics && npm run test:managed-text && npm run test:plan-text && npm run test:session-close-text && npm run test:brief-text && npm run test:generation-text && npm run test:install-text && npm run test:authoring-language && npm run test:context-reading && npm run test:rule-text && npm run test:mode-text && npm run test:handoff-text && npm run test:audit-text && npm run test:audit-readme-text && npm run test:audit-env-text && npm run test:feature-graph-text && npm run test:env-sync-text && npm run test:teams-text && npm run test:reminder-text && npm run test:handoff-history-text && npm run test:env-snapshot-text && npm run test:context-current && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && node ./scripts/review-control-probe.js && npm run test:ui-review-run && npm run test:rule-verification",
|
|
52
|
-
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication-smoke && npm run test:ui-review-target-smoke && npm run test:ui-review-authorization-smoke && npm run test:ui-review-authority-host-smoke && npm run test:ui-review-authority-floor-smoke && npm run test:ui-review-proof-use-smoke && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:ui-review-run && npm run test:rule-verification",
|
|
53
|
-
"test:commands": "npm run test:hooks && npm run test:e2e-observation && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/free-text-args-probe.js && node ./scripts/title-args-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/claim-sweep-git-probe.js && node ./scripts/claim-runtime-cache-probe.js && node ./scripts/runtime-cost-probe.js && node ./scripts/secret-scan-scope-probe.js && node ./scripts/handoff-preservation-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js && node ./scripts/crud-check-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-request-labels-probe.js && node ./scripts/review-request-signals-probe.js && node ./scripts/store-values-probe.js && node ./scripts/review-snapshot-transport-probe.js && node ./scripts/bugfix-areas-probe.js && node ./scripts/done-status-probe.js && node ./scripts/fix-evidence-language-probe.js && node ./scripts/decision-fence-offset-probe.js && node ./scripts/task-sync-roundtrip-probe.js && node ./scripts/tracker-write-language-probe.js && node ./scripts/write-path-language-probe.js && node ./scripts/write-primitive-language-probe.js && node ./scripts/dry-run-refusal-probe.js",
|
|
51
|
+
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/race-overlap-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication && npm run test:ui-review-target && npm run test:ui-review-authorization && npm run test:ui-review-authority-host && npm run test:ui-review-authority-floor && npm run test:ui-review-proof-use && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:agent-chat-stream && npm run test:encoding-diagnostics && npm run test:managed-text && npm run test:plan-text && npm run test:session-close-text && npm run test:brief-text && npm run test:generation-text && npm run test:install-text && npm run test:authoring-language && npm run test:context-reading && npm run test:rule-text && npm run test:mode-text && npm run test:handoff-text && npm run test:audit-text && npm run test:audit-readme-text && npm run test:audit-env-text && npm run test:feature-graph-text && npm run test:env-sync-text && npm run test:teams-text && npm run test:reminder-text && npm run test:handoff-history-text && npm run test:env-snapshot-text && npm run test:context-current && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && node ./scripts/review-control-probe.js && npm run test:ui-review-run && npm run test:rule-verification",
|
|
52
|
+
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/race-overlap-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication-smoke && npm run test:ui-review-target-smoke && npm run test:ui-review-authorization-smoke && npm run test:ui-review-authority-host-smoke && npm run test:ui-review-authority-floor-smoke && npm run test:ui-review-proof-use-smoke && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:ui-review-run && npm run test:rule-verification",
|
|
53
|
+
"test:commands": "npm run test:hooks && npm run test:e2e-observation && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/free-text-args-probe.js && node ./scripts/title-args-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/claim-sweep-git-probe.js && node ./scripts/claim-runtime-cache-probe.js && node ./scripts/runtime-cost-probe.js && node ./scripts/secret-scan-scope-probe.js && node ./scripts/handoff-preservation-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js && node ./scripts/crud-check-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-request-labels-probe.js && node ./scripts/review-request-signals-probe.js && node ./scripts/store-values-probe.js && node ./scripts/review-snapshot-transport-probe.js && node ./scripts/bugfix-areas-probe.js && node ./scripts/done-status-probe.js && node ./scripts/fix-evidence-language-probe.js && node ./scripts/decision-fence-offset-probe.js && node ./scripts/task-sync-roundtrip-probe.js && node ./scripts/tracker-write-language-probe.js && node ./scripts/write-path-language-probe.js && node ./scripts/write-primitive-language-probe.js && node ./scripts/dry-run-refusal-probe.js && node ./scripts/lazy-auto-track-probe.js",
|
|
54
54
|
"test:e2e-observation": "node ./scripts/e2e-temp-scope-probe.js && node ./scripts/e2e-child-diagnostics-probe.js",
|
|
55
55
|
"test:next-actions": "node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js",
|
|
56
56
|
"test:installed": "npm run test:installed-lifecycle && node ./scripts/installed-cleanroom-probe.js",
|
|
57
57
|
"test:installed-lifecycle": "node ./scripts/installed-handoff-lifecycle-probe.js",
|
|
58
58
|
"test:handoff": "node ./scripts/handoff-readonly-probe.js",
|
|
59
|
-
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:rule-verification",
|
|
59
|
+
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/race-overlap-probe.js && node ./scripts/e2e-repo-guard.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:rule-verification",
|
|
60
60
|
"test:false-claims": "node ./scripts/false-claim-probe.js",
|
|
61
61
|
"test:claim-sweep": "node ./scripts/claim-sweep-git-probe.js",
|
|
62
62
|
"test:claim-runtime": "node ./scripts/claim-runtime-cache-probe.js",
|
|
@@ -74,6 +74,8 @@
|
|
|
74
74
|
"test:tracker-write-language": "node ./scripts/tracker-write-language-probe.js",
|
|
75
75
|
"test:write-path-language": "node ./scripts/write-path-language-probe.js",
|
|
76
76
|
"test:write-primitive-language": "node ./scripts/write-primitive-language-probe.js",
|
|
77
|
+
"test:race-overlap": "node ./scripts/race-overlap-probe.js",
|
|
78
|
+
"test:lazy-auto-track": "node ./scripts/lazy-auto-track-probe.js",
|
|
77
79
|
"test:dry-run-refusal": "node ./scripts/dry-run-refusal-probe.js",
|
|
78
80
|
"test:api-skill-match": "node ./scripts/api-skill-match-probe.js",
|
|
79
81
|
"test:crud-check": "node ./scripts/crud-check-probe.js",
|
|
@@ -9,6 +9,7 @@ const cp = require('child_process');
|
|
|
9
9
|
|
|
10
10
|
const CLI = path.resolve(process.argv[2] || path.join(__dirname, '..', 'bin', 'leerness.js'));
|
|
11
11
|
const PRELOAD = path.join(__dirname, 'lock-probe.js');
|
|
12
|
+
const { overlapOf } = require('./race-overlap');
|
|
12
13
|
const ENV = {
|
|
13
14
|
...process.env,
|
|
14
15
|
LEERNESS_OFFLINE: '1',
|
|
@@ -37,16 +38,22 @@ function init(d) {
|
|
|
37
38
|
// is observed rather than inferred. T-0314: inferring it from `<target>.tmp-*` names stopped working when
|
|
38
39
|
// 1.36.190 published memory outputs through `.<target>.publish-*` + link; 8 of 11 races then never raced.
|
|
39
40
|
const readyFileOf = d => path.join(d, '.stall-ready.json');
|
|
41
|
+
// T-0346: the second command writes this file after it read the target, or tried its lock or a lock the first
|
|
42
|
+
// command held when it stalled; the first command's stall ends only after it exists (see scripts/lock-probe.js).
|
|
43
|
+
// A fixed 1.2 s stall could end before the second command had started (a Node start alone can take that long on
|
|
44
|
+
// Windows), and the race then passed without overlapping the publication.
|
|
45
|
+
const reachFileOf = d => path.join(d, '.stall-reached.json');
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const env =
|
|
46
|
-
? { ...ENV, LOCKPROBE_STALL_TARGET:
|
|
47
|
-
: ENV;
|
|
47
|
+
// role 'first' stalls the publication of `target` until the second command reaches it; role 'second' reports
|
|
48
|
+
// reaching it. Both run with the lock-probe preload.
|
|
49
|
+
function spawn(d, args, role, target) {
|
|
50
|
+
const nodeArgs = ['--require', PRELOAD, CLI, ...args, '--path', d];
|
|
51
|
+
const env = role === 'first'
|
|
52
|
+
? { ...ENV, LOCKPROBE_STALL_TARGET: target, LOCKPROBE_STALL_MS: String(STALL_MS), LOCKPROBE_READY_FILE: readyFileOf(d), LOCKPROBE_WAIT_FILE: reachFileOf(d) }
|
|
53
|
+
: { ...ENV, LOCKPROBE_REACH_TARGET: target, LOCKPROBE_REACH_FILE: reachFileOf(d), LOCKPROBE_REACH_LOCKS_FILE: readyFileOf(d) };
|
|
48
54
|
return cp.spawn(process.execPath, nodeArgs, { cwd: d, env, stdio: 'ignore' });
|
|
49
55
|
}
|
|
56
|
+
const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
50
57
|
|
|
51
58
|
function waitForStall(d, target) {
|
|
52
59
|
// T-0343: an absolute target (a file outside .leerness) is acknowledged by its full path, a relative one by its
|
|
@@ -66,15 +73,22 @@ async function race(name, config) {
|
|
|
66
73
|
if (config.seed) config.seed(d);
|
|
67
74
|
// T-0343: a target outside .leerness is given as a function of the project directory (its absolute path).
|
|
68
75
|
const stallTarget = typeof config.stallTarget === 'function' ? config.stallTarget(d) : config.stallTarget;
|
|
69
|
-
const first = spawn(d, config.first, stallTarget);
|
|
76
|
+
const first = spawn(d, config.first, 'first', stallTarget);
|
|
70
77
|
const stalled = waitForStall(d, stallTarget);
|
|
71
78
|
if (config.beforeSecond) config.beforeSecond(d);
|
|
72
|
-
const second = spawn(d, typeof config.second === 'function' ? config.second(d) : config.second);
|
|
79
|
+
const second = spawn(d, typeof config.second === 'function' ? config.second(d) : config.second, 'second', stallTarget);
|
|
73
80
|
const exits = await Promise.all([wait(first), wait(second)]);
|
|
81
|
+
// T-0346: the race counts only when the second command overlapped the first one's stalled section (see
|
|
82
|
+
// scripts/race-overlap.js): a contended lock attempt, or, for a command that tried none of those locks, a read
|
|
83
|
+
// of the stalled file that finished before the first command resumed.
|
|
84
|
+
const ready = readJson(readyFileOf(d)) || {}, reach = readJson(reachFileOf(d));
|
|
85
|
+
const { overlapped, why } = overlapOf({ stalled, ready, reach });
|
|
74
86
|
const inspected = config.inspect(d);
|
|
75
87
|
// T-0343: `secondExit` names the second command's expected exit when it is not 0 (lazy detect reports issues with 1).
|
|
76
88
|
const secondExit = config.secondExit === undefined ? 0 : config.secondExit;
|
|
77
|
-
return { name, stalled,
|
|
89
|
+
return { name, stalled, overlapped, why, read: reach && reach.read && reach.read.how, lock: reach && reach.lock && reach.lock.result,
|
|
90
|
+
waitedMs: ready.waitedMs, exits, ...inspected,
|
|
91
|
+
ok: overlapped && exits[0] === 0 && exits[1] === secondExit && inspected.preserved === true };
|
|
78
92
|
} catch (e) {
|
|
79
93
|
return { name, error: String(e && e.message || e), ok: false };
|
|
80
94
|
} finally {
|
|
@@ -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 };
|