Commit Graph

10 Commits

Author SHA1 Message Date
Tom Boucher
a8ff46484b fix(#474): deterministic STATE dates via deepened clock seam (nowIso/today + GSD_NOW_MS adapter) (#477)
* fix(#474): route state date-stamping + installer lock loop through clock seam (nowIso/today + GSD_NOW_MS adapter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#474): use process.ppid (not pid 1) as held-lock owner in install-lock timeout test

pid 1 is POSIX init/launchd (always alive) but does not exist on Windows,
so isPidAlive(1) returns false, the lock is reclaimed as stale, and
acquireInstallMigrationLock no longer throws -- failing the timeout
assertion on windows-latest,22. process.ppid is a live, non-self process
on every platform, so the lock is seen as held and the timeout path
throws deterministically cross-platform.

Refs #474

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:21:25 -04:00
Tom Boucher
172e6920eb fix(3670): break migration lock self-deadlock on Windows (#3765)
* test(installer): add regression tests for #3670 migration lock self-deadlock

- T1: same-process PID re-entry reclamation (primary regression)
- T2: dead-PID stale lock reclamation
- T3: unlinkSync EPERM surfaces (not silently swallowed via force:true)
- T4: counter-test — normal round-trip still works
- T5: counter-test — genuinely-held live lock still errors clearly
- Update existing 'reports lock release failures' test to mock
  fs.unlinkSync (not fs.rmSync) matching the fixed release path

Windows wall-clock deadlock repro depends on Docker matrix Windows runners.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(installer): break migration lock self-deadlock on Windows (#3670)

Root cause: `acquireInstallMigrationLock` release closure called
`fs.rmSync(lockPath, { force: true })`. On Windows NTFS, a file
recently closed via `closeSync(fd)` may still return EPERM from
`unlink` until the OS fully releases the handle. The `force: true`
flag silently swallows EPERM, leaving the lock file on disk. The
subsequent `runInstallerMigrations` call in the same install()
invocation hits EEXIST, spins for 30 s, then throws
"installer migration lock is held".

Fix:
1. Release closure uses `fs.unlinkSync` (not rmSync+force) so
   EPERM propagates via releaseError instead of being swallowed.
2. `acquireInstallMigrationLock` closes the fd before writing the
   payload (path-based write), eliminating the open handle that
   caused the deferred EPERM on Windows.
3. Stale-lock reclamation: on EEXIST, parse the on-disk PID and
   reclaim immediately if it matches process.pid (same-process
   re-entry, the primary #3670 failure mode) or if the PID is
   dead (ESRCH). Live alien PIDs still trigger the 30 s timeout.
4. Error message on a genuinely-held lock now includes the holder
   PID and acquiredAt timestamp for operator diagnostics.

No public API change. All callers of runInstallerMigrations are
inside installer-migrations.cjs and bin/install.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(3670): update changeset to reference PR #3765

* fix(3670): timeout on reclaim-unlink failure to prevent spin-loop regression

When unlinkSync throws (e.g. Windows EPERM on an open handle) in the
same-PID / dead-PID reclamation path, the original code continued
unconditionally — bypassing the timeout check and reintroducing the
exact deadlock the PR is supposed to fix.

Guard the continue behind a `reclaimed` flag: only loop back to
openSync if unlink SUCCEEDED. On failure, fall through to the existing
bounded sleep + timeout, which surfaces "installer migration lock is
held" within lockTimeoutMs instead of spinning indefinitely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(3670): tighten T5 — assert bounded failure when reclaim unlink fails on live lock

The old T5 accepted BOTH success and throw, which allowed over-reclamation
of a genuinely un-reclaimable lock to pass undetected.

Rewrite T5 to force deterministically unreclaimable conditions:
- Pre-seed lock with process.pid (triggers isSameProcess path)
- Mock fs.unlinkSync via mock.method() to throw EPERM for the lock file

With the production fix: reclaimed=false → falls through to timeout →
throws "installer migration lock is held" within ~200ms.

Without the production fix: unlink throws but continue runs anyway →
process spins and eventually OOMs (confirmed RED: 136s runtime, V8 heap
exhaustion from infinite readLockFile + new Error() allocations).

assert.throws() now makes success a hard failure, closing the
over-reclamation gap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3670): clean up orphan lock file when writeFileSync fails after closeSync

If closeSync(fd) succeeds (fd=null) but the subsequent writeFileSync
throws, the empty lock file was left on disk. readLockFile returns null
for an empty/invalid-JSON file, so the stale-lock reclamation path
skips it, causing the next acquire attempt to spin to timeout.

Track ownership with lockCreatedByUs flag; add a second cleanup branch
in the catch block for the fd-already-closed case.

Also fix changeset body to use the bold-prefix format required by all
other fragments in .changeset/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:12:08 -04:00
Tom Boucher
1e091d2bcb refactor(shell-projection): remove deprecated wrappers + finalize ADRs (Phase 4, #3468) (#3484)
* refactor(shell-projection): remove deprecated wrappers + finalize ADRs (Phase 4, #3468)

Final phase of the shell-command-projection expansion. Removes the legacy
core.cjs wrappers (`atomicWriteFileSync`, `safeReadFile`, `normalizeMd`)
now that every call site lives behind the seam, plus three Phase-3
stragglers (`graphify.cjs`, `template.cjs`, dead import in
`profile-pipeline.cjs`).

Documentation:
- ADR-0009: addendum noting Phase 1–4 scope expansion (subprocess +
  file I/O ownership), supersession of "does not execute" constraint,
  and resolution of open Q4.
- ADR-0010: status changed to Superseded by ADR-0009 with explanation.
- CONTEXT.md "Shell Command Projection Module" entry already current
  from Phase 1 — no edit needed.

Tests:
- `tests/atomic-write.test.cjs` deleted — wrapper it tested is gone;
  `atomic-write-coverage.test.cjs` (Phase 3) covers platformWriteSync.
- `tests/core.test.cjs::safeReadFile` + `::normalizeMd` describes
  deleted — wrappers are gone.
- `tests/concurrency-safety.test.cjs` normalizeMd suite (behavioral /
  perf / snapshot) repointed via 2-line shim at the seam's
  `normalizeContent` — full regression coverage preserved.

Test result: 9059/9041/18 — exact pre-Phase-4 baseline. All 18
failures are pre-existing path-with-spaces local-env issues.

Closes #3468

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(shell-projection): migrate remaining raw fs.writeFileSync sites (Phase 4, #3468)

Sweeps the 7 raw fs.writeFileSync call sites that bypassed the seam through Phase 3,
folding them into platformWriteSync. Net -14 lines: deletes the local writeFileAtomicSync
helper in installer-migrations.cjs and collapses surface.cjs's manual tmp+rename into a
single seam call.

Sites migrated:
- drift.cjs (1) — frontmatter write
- learnings.cjs (1) — learning record JSON write
- install-profiles.cjs (1) — profile marker write (collapsed redundant mkdir)
- gsd2-import.cjs (1) — imported file write (collapsed redundant mkdir)
- surface.cjs (1) — surface state write (replaced manual tmp+rename block)
- installer-migrations.cjs (3) — journal init/finalize + rewrite-json action;
  deleted private writeFileAtomicSync helper and its three call sites

Two sites intentionally retained outside the seam:
- planning-workspace.cjs:241 — workspace lock (wx-flag atomic-create; previously excluded by Phase 3)
- installer-migrations.cjs:220 — install migration lock (fd write into wx-opened handle)
- writeInstallState (installer-migrations.cjs) — strict atomic contract for install state;
  the seam's fallback-to-direct-write on rename failure would silently violate the
  invariant that install state must never be left half-written. Inline tmp+rename with
  rethrow keeps the original guarantee.

Tests: 9059 / 9041 / 18 — exactly the pre-Phase-4 baseline; 18 failures are the
pre-existing path-with-spaces local-env issues, identical files as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(installer-migrations): use strict atomic write for rollback install-state restore

The rollback path was restoring INSTALL_STATE via platformWriteSync, which falls
back to a direct write on rename failure and would silently violate the
half-written invariant that the install-state contract guarantees elsewhere.

Extracts the strict tmp+rename logic from writeInstallState into a shared
atomicWriteInstallState(configDir, content) helper and routes both
writeInstallState and rollbackAppliedMigrationResult through it. Preserves the
existing null-handling (rmSync when previousInstallStateBytes === null) and
existing failure-collection (failures.push on caught errors).

Byte-faithful restore: previousInstallStateBytes is written as-is (no JSON
parse round-trip), preserving the exact prior file contents on restore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:46:02 -04:00
Tom Boucher
7a0a7f1300 Merge main into phase 5 installer migrations 2026-05-11 15:07:50 -04:00
Tom Boucher
908a19cd04 fix: harden installer migration integration 2026-05-11 14:36:14 -04:00
Tom Boucher
a1f00a8a0c Feat(installer): add phase 5 migration guardrails 2026-05-11 10:44:57 -04:00
Tom Boucher
7fe75c2c3e Feat(installer): harden phase 4 migration integration 2026-05-11 10:02:02 -04:00
Tom Boucher
3084ecc2a6 Add first-time installer baseline migration 2026-05-10 23:25:11 -04:00
Tom Boucher
3943146484 feat: migrate legacy codex hooks cleanup 2026-05-10 23:08:35 -04:00
Tom Boucher
6d33055756 feat: add installer migration framework 2026-05-10 22:23:28 -04:00