Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/<name>/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
Expand Down
56 changes: 52 additions & 4 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,16 +335,16 @@ 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".
* @param {number} pid
* @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',
Expand All @@ -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/<name>/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;
}
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions tests/_isolate-home.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion tests/ask.test.js
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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];
Expand Down
27 changes: 26 additions & 1 deletion tests/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion tests/daemon-relay-only.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
Expand Down
3 changes: 2 additions & 1 deletion tests/discovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
});

Expand Down
2 changes: 2 additions & 0 deletions tests/identity-halt.test.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/e2e-admission.js
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/e2e-attestation-gossip.js
Original file line number Diff line number Diff line change
@@ -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).
*
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/e2e-checkpoint-witness.js
Original file line number Diff line number Diff line change
@@ -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).
*
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/e2e-cmb-path.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/e2e-payload-receive.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down
2 changes: 2 additions & 0 deletions tests/p6-legacy-grandfather.test.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down
2 changes: 2 additions & 0 deletions tests/rule-a-collapse.test.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down
1 change: 1 addition & 0 deletions tests/wire-rejection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading