diff --git a/CHANGELOG.md b/CHANGELOG.md index ee67524..d9afa3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.13.13 (unreleased) + +### Fixed — a crashed Windows session no longer locks its node name until reboot + +The identity lock records its holder's process start time, so a later start can tell a live holder from an +unrelated process that reused the holder's PID. On Windows the start time came from `ps`, which does not +exist there, so no start time was recorded. After a crash or a hard-killed terminal, once Windows reused the +PID, the name stayed locked (`EIDENTITYLOCK`) for the rest of the boot. + +- **Windows start time.** Windows now reads the start time through the system PowerShell, by absolute path, + as UTC ISO-8601. The lock writer and a later reader use the same form. +- **Caching.** Results are cached for 10 s per PID. A daemon checking many node directories held by one + process therefore pays one lookup, about 0.45 s on Windows, measured. +- **Failed lookups.** When the start time cannot be read (no PowerShell, a timeout, access denied), the lock + is treated as held, as before, and the process warns once. A failed lookup of the node's own start time is + retried at the next acquire instead of being remembered. + +Limits: + +- A lock written by 0.13.12 or earlier carries no start time, so it can still only be cleared by a reboot or + by deleting `~/.sym/nodes//lock.pid`. Guessing from the file's timestamp was considered and dropped: + a clock step could make a live holder look stale, and a live holder must never lose its lock. +- Locks written from 0.13.13 on are covered. + ## 0.13.12 (2026-10-01) Fixes from an MMP 2.0 conformance audit of sym-mesh-channel and the SDK it runs, revised after an independent diff --git a/lib/config.js b/lib/config.js index 014e95d..be431d4 100644 --- a/lib/config.js +++ b/lib/config.js @@ -335,8 +335,8 @@ function bootTimeMs() { /** * The kernel start time of a process, as an opaque stable string - * (`ps -o lstart=`), or null when it cannot be determined (dead PID, - * Windows, ps unavailable). Two different processes that reuse the same + * (`ps -o lstart=`, or ISO-8601 UTC from PowerShell on Windows), or null + * when it cannot be determined (dead PID, ps/PowerShell unavailable, access denied). Two different processes that reuse the same * PID number have different start times — this is what lets the identity * lock distinguish "our previous holder is still running" from "an * unrelated process recycled the holder's PID". @@ -344,7 +344,7 @@ function bootTimeMs() { * @returns {string|null} */ function processStartTime(pid) { - if (process.platform === 'win32') return null; + if (process.platform === 'win32') return windowsProcessStartTime(pid); try { const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', @@ -357,13 +357,59 @@ function processStartTime(pid) { } } +/** + * Windows has no `ps`. PowerShell reports a process's start time; the UTC ISO-8601 form is stable + * across calls and locales, so the lock writer (for itself) and a later reader (for the same PID) + * produce the same string. Without it, a Windows lock recorded no start time and a crashed + * holder's recycled PID kept the name locked until reboot. + * + * Failure (no PowerShell, a timeout, access denied for another user's or a protected process) + * returns null, which every reader treats as "held": the safe direction, and the pre-0.13.13 + * behaviour. A one-time warning says so. Results are cached briefly per PID, because a daemon + * checks every node directory's lock at start and many of them share one holder process. + * @param {number} pid + * @returns {string|null} + */ +function windowsProcessStartTime(pid) { + if (!Number.isInteger(pid) || pid <= 0 || pid > 0xffffffff) return null; + const cached = _winStartCache.get(pid); + if (cached && Date.now() - cached.at < WIN_START_CACHE_MS) return cached.value; + let value = null; + try { + const out = execFileSync(windowsPowerShellPath(), [ + '-NoProfile', '-NonInteractive', '-Command', + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`, + ], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); + value = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(out) ? out : null; + } catch { + value = null; + } + if (value === null && !_winStartWarned) { + _winStartWarned = true; + process.emitWarning(`SYM identity lock: could not read the start time of process ${pid}; ` + + 'a lock held by that PID is treated as live (delete ~/.sym/nodes//lock.pid if its holder is gone).'); + } + _winStartCache.set(pid, { value, at: Date.now() }); + return value; +} +const WIN_START_CACHE_MS = 10000; +const _winStartCache = new Map(); +let _winStartWarned = false; + +/** The system PowerShell by absolute path, never whatever `powershell.exe` the search path finds first. */ +function windowsPowerShellPath() { + const root = process.env.SystemRoot || process.env.windir || 'C:\\Windows'; + return path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); +} + let _selfStartTime = null; let _selfStartTimeResolved = false; /** processStartTime(process.pid), computed once per process. */ function selfStartTime() { if (!_selfStartTimeResolved) { _selfStartTime = processStartTime(process.pid); - _selfStartTimeResolved = true; + // A failed lookup (a transient timeout) is not cached: the next acquire tries again. + _selfStartTimeResolved = _selfStartTime !== null; } return _selfStartTime; } @@ -639,6 +685,8 @@ module.exports = { normalizeMdnsHostname, pidIsAlive, processStartTime, + /** Test hook: forget cached Windows start times, so a test reaches PowerShell on every call. */ + _clearProcessStartTimeCache: () => _winStartCache.clear(), readLockFile, lockIsHeldByLiveProcess, lockHolderPid, diff --git a/tests/_isolate-home.js b/tests/_isolate-home.js index 6dfa4f5..1fe53c0 100644 --- a/tests/_isolate-home.js +++ b/tests/_isolate-home.js @@ -23,6 +23,9 @@ const path = require('path'); const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-test-home-')); process.env.HOME = sandbox; +// os.homedir() reads USERPROFILE on Windows, not HOME: without this, Windows runs wrote into the +// real ~/.sym and the ask tests read the user's real mesh memory. +process.env.USERPROFILE = sandbox; process.once('exit', () => { try { diff --git a/tests/ask.test.js b/tests/ask.test.js index a24d86b..e7270ba 100644 --- a/tests/ask.test.js +++ b/tests/ask.test.js @@ -1,5 +1,7 @@ 'use strict'; +require('./_isolate-home'); // sandbox HOME/USERPROFILE before anything reads os.homedir() + // `sym ask` — ask the whole mesh one question, get one answer. // // These tests run the CLI offline: no daemon, no LLM provider (env cleared), @@ -18,7 +20,8 @@ const CLI = path.join(__dirname, '..', 'bin', 'sym.js'); // Run `sym ask` with a throwaway mesh home and no LLM provider configured. function runAsk(homeDir, argv) { - const env = { ...process.env, HOME: homeDir }; + // USERPROFILE too: os.homedir() reads it on Windows, where HOME alone left the child on the real profile. + const env = { ...process.env, HOME: homeDir, USERPROFILE: homeDir }; // Force the no-provider path so the test is hermetic and free. for (const k of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'SYM_LLM_API_KEY', 'SYM_LLM_PROVIDER', 'CLAUDE_AGENT_MODEL']) { delete env[k]; diff --git a/tests/config.test.js b/tests/config.test.js index 676972f..a245107 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -12,7 +12,7 @@ const { SYM_DIR, NODES_DIR, ensureDir, nodeDir, uuidv7, validateName, generateSigningKeyPair, loadOrCreateIdentity, normalizeMdnsHostname, pidIsAlive, lockHolderPid, log, - acquireIdentityLock, readLockFile, processStartTime, + acquireIdentityLock, readLockFile, processStartTime, _clearProcessStartTimeCache, } = require('../lib/config'); describe('uuidv7', () => { @@ -331,6 +331,31 @@ describe('acquireIdentityLock', () => { release(); }); + it('keeps a lock whose live holder\'s recorded start time matches (a live holder is never reclaimed)', () => { + const name = mkName(); + const start = processStartTime(liveChild.pid); + assert.ok(start, 'precondition: this platform can read a process start time'); + writeLock(name, `${liveChild.pid}\n${JSON.stringify({ start, createdAt: Date.now() })}\n`); + assert.throws(() => acquireIdentityLock(name), (e) => e.code === 'EIDENTITYLOCK' && e.holderPid === liveChild.pid); + }); + + it('on Windows, a process start time is read and is stable', { skip: process.platform !== 'win32' }, () => { + _clearProcessStartTimeCache(); + const a = processStartTime(process.pid); + assert.match(String(a), /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/, 'UTC ISO-8601 from PowerShell'); + _clearProcessStartTimeCache(); // a second real PowerShell call, not the cache + assert.strictEqual(processStartTime(process.pid), a, 'same process, same string'); + assert.notStrictEqual(processStartTime(liveChild.pid), a, 'a different process differs'); + }); + + it('on Windows, a lock recorded with a recycled PID\'s old start time is reclaimed', { skip: process.platform !== 'win32' }, () => { + const name = mkName(); + writeLock(name, `${liveChild.pid}\n{"start":"2004-01-01T00:00:00.0000000Z","createdAt":1}\n`); + const release = acquireIdentityLock(name); + assert.strictEqual(readLockFile(lockPathOf(name)).pid, process.pid); + release(); + }); + it('reclaims an aged-out corrupt lockfile but respects a fresh one', () => { const name = mkName(); writeLock(name, 'garbage'); diff --git a/tests/daemon-relay-only.test.js b/tests/daemon-relay-only.test.js index 3421697..b7c6ca0 100644 --- a/tests/daemon-relay-only.test.js +++ b/tests/daemon-relay-only.test.js @@ -20,7 +20,7 @@ test('SYM_RELAY_ONLY=1: LAN discovery off, relay-auth still sent', async () => { wss.on('connection', (ws) => ws.on('message', (m) => { const f = JSON.parse(String(m)); if (f.type === 'relay-auth') auths.push(f); })); const relayUrl = `ws://127.0.0.1:${wss.address().port}`; const daemon = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'sym-daemon.js')], { - env: { ...process.env, HOME: home, SYM_SOCKET: path.join(home, 'd.sock'), SYM_NODE_NAME: 'relay-only-test', + env: { ...process.env, HOME: home, USERPROFILE: home, SYM_SOCKET: path.join(home, 'd.sock'), SYM_NODE_NAME: 'relay-only-test', SYM_ROOM: 'relay-only-room', SYM_RELAY_ONLY: '1', SYM_RELAY_URL: relayUrl, SYM_RELAY_TOKEN: 'x'.repeat(32) }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/tests/discovery.test.js b/tests/discovery.test.js index 34f426e..4347452 100644 --- a/tests/discovery.test.js +++ b/tests/discovery.test.js @@ -9,6 +9,7 @@ const os = require('node:os'); const path = require('node:path'); const realHome = os.homedir(); process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-discovery-home-')); +process.env.USERPROFILE = process.env.HOME; // os.homedir() reads USERPROFILE on Windows const { describe, it } = require('node:test'); const assert = require('node:assert'); @@ -145,7 +146,7 @@ describe('loopback self-clean on abrupt exit', () => { process.exit(0); `; const r = spawnSync(process.execPath, ['-e', src], { - env: { ...process.env, HOME: tmpHome }, + env: { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome }, encoding: 'utf8', }); diff --git a/tests/identity-halt.test.js b/tests/identity-halt.test.js index b6425fa..f3c86a3 100644 --- a/tests/identity-halt.test.js +++ b/tests/identity-halt.test.js @@ -1,5 +1,7 @@ 'use strict'; +require('./_isolate-home'); // sandbox HOME/USERPROFILE before anything reads os.homedir() + /** * B-3 / AC-3.2 — the system refuses to start rather than replace an identity. * diff --git a/tests/integration/e2e-admission.js b/tests/integration/e2e-admission.js index 47e0150..e715327 100644 --- a/tests/integration/e2e-admission.js +++ b/tests/integration/e2e-admission.js @@ -1,5 +1,7 @@ 'use strict'; +require('../_isolate-home'); // sandbox HOME/USERPROFILE: node state must never land in the real ~/.sym + /** * End-to-end Admission Attestation — the gate attaches a signed attestation to the * remix it stores (MMP admission-attestation layer, Phase C). diff --git a/tests/integration/e2e-attestation-gossip.js b/tests/integration/e2e-attestation-gossip.js index 9c5c23e..878885e 100644 --- a/tests/integration/e2e-attestation-gossip.js +++ b/tests/integration/e2e-attestation-gossip.js @@ -1,5 +1,7 @@ 'use strict'; +require('../_isolate-home'); // sandbox HOME/USERPROFILE: node state must never land in the real ~/.sym + /** * End-to-end Admission Attestation gossip (Phase D2). * diff --git a/tests/integration/e2e-checkpoint-witness.js b/tests/integration/e2e-checkpoint-witness.js index b9b2e8d..5a3ddfd 100644 --- a/tests/integration/e2e-checkpoint-witness.js +++ b/tests/integration/e2e-checkpoint-witness.js @@ -1,5 +1,7 @@ 'use strict'; +require('../_isolate-home'); // sandbox HOME/USERPROFILE: node state must never land in the real ~/.sym + /** * End-to-end checkpoint + witness cycle (Phase D3). * diff --git a/tests/integration/e2e-cmb-path.js b/tests/integration/e2e-cmb-path.js index 1313254..d925450 100644 --- a/tests/integration/e2e-cmb-path.js +++ b/tests/integration/e2e-cmb-path.js @@ -1,5 +1,7 @@ 'use strict'; +require('../_isolate-home'); // sandbox HOME/USERPROFILE: node state must never land in the real ~/.sym + /** * End-to-end CMB path integration test — MMP §4.2 wire frame, §4.4.4 * targeted routing envelope, §9.2 receiver-autonomous SVAF evaluation, diff --git a/tests/integration/e2e-payload-receive.js b/tests/integration/e2e-payload-receive.js index 7210a5a..59b39d7 100644 --- a/tests/integration/e2e-payload-receive.js +++ b/tests/integration/e2e-payload-receive.js @@ -1,5 +1,7 @@ 'use strict'; +require('../_isolate-home'); // sandbox HOME/USERPROFILE: node state must never land in the real ~/.sym + /** * End-to-end PAYLOAD receive integration test — the cross-device drop hunt. * diff --git a/tests/p6-legacy-grandfather.test.js b/tests/p6-legacy-grandfather.test.js index c453db0..38db329 100644 --- a/tests/p6-legacy-grandfather.test.js +++ b/tests/p6-legacy-grandfather.test.js @@ -1,5 +1,7 @@ 'use strict'; +require('./_isolate-home'); // sandbox HOME/USERPROFILE: node state must never land in the real ~/.sym + /** * P-6 — a pre-boundary block is UNATTESTED, not FORGED. * diff --git a/tests/rule-a-collapse.test.js b/tests/rule-a-collapse.test.js index 981058d..91210a0 100644 --- a/tests/rule-a-collapse.test.js +++ b/tests/rule-a-collapse.test.js @@ -1,5 +1,7 @@ 'use strict'; +require('./_isolate-home'); // sandbox HOME/USERPROFILE: node state must never land in the real ~/.sym + /** * B-2 / AC-2.4 — Rule A and the collapse property. * diff --git a/tests/wire-rejection.test.js b/tests/wire-rejection.test.js index babe045..5b25ef9 100644 --- a/tests/wire-rejection.test.js +++ b/tests/wire-rejection.test.js @@ -9,6 +9,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-wire-rejection-home-')); +process.env.USERPROFILE = process.env.HOME; // os.homedir() reads USERPROFILE on Windows const { describe, it } = require('node:test'); const assert = require('node:assert');