diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js index f55ddf511..82aa7a4f6 100755 --- a/scripts/plan-canvas.js +++ b/scripts/plan-canvas.js @@ -182,7 +182,17 @@ function processIsAlive(pid) { } } +let currentProcessIdentity = null; + function readProcessIdentity(pid) { + // Only our own PID is safe to cache: other processes can exit and reuse a PID. + if (pid === process.pid && currentProcessIdentity !== null) return currentProcessIdentity; + const identity = queryProcessIdentity(pid); + if (pid === process.pid && identity !== null) currentProcessIdentity = identity; + return identity; +} + +function queryProcessIdentity(pid) { if (!Number.isInteger(pid) || pid <= 0) return null; try { if (process.platform === 'linux') { @@ -198,7 +208,8 @@ function readProcessIdentity(pid) { const startTicks = execFileSync( 'powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, windowsHide: true } + // Cold PowerShell startup on loaded Windows runners can exceed three seconds. + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000, windowsHide: true } ).trim(); return startTicks ? `win32:${startTicks}` : null; } diff --git a/tests/integration/plan-canvas-e2e.test.js b/tests/integration/plan-canvas-e2e.test.js index 75667ac15..d62d446bd 100644 --- a/tests/integration/plan-canvas-e2e.test.js +++ b/tests/integration/plan-canvas-e2e.test.js @@ -22,6 +22,8 @@ const fs = require('fs'); const http = require('http'); const os = require('os'); const path = require('path'); +const vm = require('vm'); +const { createRequire } = require('module'); const { spawn, spawnSync } = require('child_process'); const CLI = path.join(__dirname, '..', '..', 'scripts', 'plan-canvas.js'); @@ -144,6 +146,41 @@ async function main() { let key = null; try { + await test('Windows startup identity tolerates slow PowerShell and reuses the current process identity', async () => { + const module = { exports: {} }; + const cliRequire = createRequire(CLI); + let lookups = 0; + vm.runInNewContext(fs.readFileSync(CLI, 'utf8'), { + module, + require: name => name === 'child_process' ? { + execFileSync: (command, args, options) => { + assert.strictEqual(command, 'powershell.exe'); + lookups += 1; + // Model a cold PowerShell launch exceeding the old three-second budget. + if (options.timeout < 5000) throw Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }); + return '638920000000000000\r\n'; + } + } : cliRequire(name), + process: { pid: process.pid, platform: 'win32', kill: process.kill.bind(process) }, + setTimeout, + URL, + Buffer + }, { filename: CLI }); + const lockDir = path.join(tmp, 'windows-startup-locks'); + let active = 0; + let maximumActive = 0; + const runLocked = () => module.exports.withServerStartLock(port + 7, async () => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise(resolve => setTimeout(resolve, 40)); + active -= 1; + }, { lockDir, timeoutMs: 2000 }); + await Promise.all([runLocked(), runLocked()]); + assert.strictEqual(maximumActive, 1); + assert.strictEqual(lookups, 1, 'same-process tickets must reuse the successful identity lookup'); + assert.deepStrictEqual(fs.readdirSync(lockDir), []); + }); + await test('port-scoped startup lock serializes server replacement callers', async () => { const lockDir = path.join(tmp, 'startup-locks'); let active = 0;