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
1 change: 1 addition & 0 deletions packages/stim-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@stim-cli/metro": "workspace:^",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"unique-pid": "0.1.1",
"ws": "^8.21.3"
},
"devDependencies": {
Expand Down
38 changes: 16 additions & 22 deletions packages/stim-cli/src/__tests__/android-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import assert from 'node:assert';
import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
import { captureProcessToken } from '../process-identity.ts';
import { once } from 'node:events';
import { type ChildProcess, spawn } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand Down Expand Up @@ -70,7 +72,7 @@ let root: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'stim-home-'));
process.env.STIM_HOME = home;
root = mkdtempSync(join(tmpdir(), 'stim-android-'));
root = realpathSync(mkdtempSync(join(tmpdir(), 'stim-android-')));
writeFileSync(
join(root, 'package.json'),
JSON.stringify({
Expand Down Expand Up @@ -2227,26 +2229,16 @@ describe('Contract 4: state.json.lastBuild', () => {
});
});

function collectorProcessCommand(pid: number): string {
try {
return execFileSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf-8' }).trim();
} catch {
return '';
}
}

// Spawns a real detached process so the default `verifyCollectorOwnership` reads its actual
// live command (via ps on darwin, via /proc/[pid]/cmdline on linux) instead of a mocked
// executor, which only exercises the darwin ps path and fails closed for the wrong reason on
// Linux CI.
async function spawnFakeCollector(title: string | null): Promise<ChildProcess> {
const rename = title ? `process.title = ${JSON.stringify(title)};` : '';
const child = spawn(process.execPath, ['-e', `${rename} setInterval(() => {}, 1000);`], { stdio: 'ignore' });
const expected = title ?? process.execPath;
const deadline = Date.now() + 10_000;
while (Date.now() < deadline && !collectorProcessCommand(child.pid as number).startsWith(expected)) {
await new Promise((r) => setTimeout(r, 25));
}
const child = spawn(
process.execPath,
['-e', `${rename} process.stdout.write('ready'); setInterval(() => {}, 1000);`],
{
stdio: ['ignore', 'pipe', 'ignore'],
},
);
await once(child.stdout!, 'data');
return child;
}

Expand Down Expand Up @@ -2319,12 +2311,14 @@ describe('Contract 5: the device-log collector', () => {
expect(h.stderr.some((l) => /pid 4242/.test(l) && /not signalled/.test(l))).toBeTruthy();
});

test('the default ownership check is wired through: a live process titled for this workspace is signalled', async () => {
test('the default ownership check is wired through: a live process with a persisted identity is signalled', async () => {
const child = await spawnFakeCollector(collectorProcessTitle('android', root));
try {
writeWorkspaceState(root, {
collectors: { android: { pid: child.pid, processToken: captureProcessToken(child.pid!) } },
});
const signalled: Array<[number, NodeJS.Signals]> = [];
const result = killPreviousCollector(root, {
collectors: { android: { pid: child.pid as number } },
kill: (pid, sig) => {
signalled.push([pid, sig]);
return true;
Expand Down
203 changes: 56 additions & 147 deletions packages/stim-cli/src/__tests__/collector-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -1,155 +1,64 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs';
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { collectorProcessTitle, matchesCollectorProcess, verifyCollectorOwnership } from '../collector/ownership.ts';
import { once } from 'node:events';
import { decode } from 'unique-pid';
import { getExecutor } from '../exec.ts';
import { captureProcessToken } from '../process-identity.ts';
import { verifyCollectorOwnership } from '../collector/ownership.ts';
import { registerCollector, unregisterCollector, readCollectors } from '../collector/state.ts';

const ROOT = '/w/project';
const alive = () => true;
const dead = () => false;

function titleArgs(platform: string, root: string): string[] {
return collectorProcessTitle(platform, root).split(' ');
}

describe('matchesCollectorProcess', () => {
test('the running collector title carries the platform and the root it was started for', () => {
expect(collectorProcessTitle('ios', ROOT)).toBe('stim-collector-ios --root /w/project');
expect(matchesCollectorProcess(titleArgs('ios', ROOT), { platform: 'ios', root: ROOT })).toBe(true);
expect(matchesCollectorProcess(titleArgs('android', ROOT), { platform: 'android', root: ROOT })).toBe(true);
});

test('the spawned argv matches before the collector renames itself', () => {
const argv = [
'/usr/local/bin/node',
'/opt/stim/dist/collector-run.mjs',
'--platform',
'ios',
'--root',
ROOT,
'--udid',
'U1',
'--bundle',
'com.example.app',
];
expect(matchesCollectorProcess(argv, { platform: 'ios', root: ROOT })).toBe(true);
expect(matchesCollectorProcess(argv, { platform: 'android', root: ROOT })).toBe(false);
});

test('a collector for another root is not this workspace collector', () => {
expect(matchesCollectorProcess(titleArgs('ios', '/w/other'), { platform: 'ios', root: ROOT })).toBe(false);
expect(matchesCollectorProcess(titleArgs('ios', '/w/project-2'), { platform: 'ios', root: ROOT })).toBe(false);
});

test('a collector for another platform is not this record', () => {
expect(matchesCollectorProcess(titleArgs('android', ROOT), { platform: 'ios', root: ROOT })).toBe(false);
});

test('anything that is not a Stim collector is refused, including a lookalike', () => {
expect(matchesCollectorProcess(['node', '/w/project/index.js'], { platform: 'ios', root: ROOT })).toBe(false);
expect(matchesCollectorProcess(['vitest', '--root', ROOT], { platform: 'ios', root: ROOT })).toBe(false);
expect(matchesCollectorProcess(['stim-supervisor', '--root', ROOT], { platform: 'ios', root: ROOT })).toBe(false);
expect(matchesCollectorProcess(['stim-collector-ios'], { platform: 'ios', root: ROOT })).toBe(false);
expect(matchesCollectorProcess([], { platform: 'ios', root: ROOT })).toBe(false);
expect(matchesCollectorProcess(null, { platform: 'ios', root: ROOT })).toBe(false);
});

test('an unquoted root with a space still matches its own record', () => {
const spaced = '/w/My Project';
expect(matchesCollectorProcess(titleArgs('ios', spaced), { platform: 'ios', root: spaced })).toBe(true);
expect(matchesCollectorProcess(titleArgs('ios', spaced), { platform: 'ios', root: '/w/My' })).toBe(false);
});

test('Linux procfs hands back the renamed title as one padded argument', () => {
expect(matchesCollectorProcess([collectorProcessTitle('android', ROOT)], { platform: 'android', root: ROOT })).toBe(
true,
);
expect(
matchesCollectorProcess([collectorProcessTitle('android', '/w/other')], { platform: 'android', root: ROOT }),
).toBe(false);
});

test('a symlinked root resolves to the same collector', () => {
const dir = mkdtempSync(join(tmpdir(), 'stim-ownership-'));
const target = join(dir, 'target');
const link = join(dir, 'link');
try {
mkdirSync(target);
symlinkSync(target, link);
expect(matchesCollectorProcess(titleArgs('ios', target), { platform: 'ios', root: link })).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'stim-owned-collector-'));
process.env.STIM_HOME = home;
});
afterEach(() => {
delete process.env.STIM_HOME;
rmSync(home, { recursive: true, force: true });
});

describe('verifyCollectorOwnership', () => {
test('a proven collector is ours', () => {
expect(
verifyCollectorOwnership({
pid: 111,
platform: 'ios',
root: ROOT,
isAlive: alive,
readArgs: () => titleArgs('ios', ROOT),
}),
).toEqual({ status: 'ours' });
});

test('a live pid running something else is unverified, not ours', () => {
const result = verifyCollectorOwnership({
pid: 111,
platform: 'ios',
root: ROOT,
isAlive: alive,
readArgs: () => ['/usr/bin/node', 'server.js'],
});
expect(result.status).toBe('unverified');
expect(result).toMatchObject({
reason: expect.stringContaining("does not run this workspace's ios log collector"),
});
});

test('a collector for a different root is unverified', () => {
expect(
verifyCollectorOwnership({
pid: 111,
platform: 'ios',
root: ROOT,
isAlive: alive,
readArgs: () => titleArgs('ios', '/w/other'),
}).status,
).toBe('unverified');
});

test('an unreadable command on a live pid is unverified, never proven', () => {
test('persisted identity recognizes a live child without argv and refuses other workspaces, platforms and lifetimes', async () => {
const root = join(home, 'project with spaces');
const alias = join(home, 'alias');
mkdirSync(root);
symlinkSync(root, alias);
const child = getExecutor().spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
const exited = once(child, 'exit');
try {
await once(child, 'spawn');
const pid = child.pid!;
const processToken = captureProcessToken(pid);
expect(processToken).toBeTruthy();
registerCollector(root, 'ios', { pid, processToken });
expect(verifyCollectorOwnership({ pid, root, platform: 'ios' })).toEqual({ status: 'ours' });
expect(verifyCollectorOwnership({ pid, root: alias, platform: 'ios' })).toEqual({ status: 'ours' });
expect(verifyCollectorOwnership({ pid, root, platform: 'android' }).status).toBe('unverified');
expect(verifyCollectorOwnership({ pid, root: join(home, 'other'), platform: 'ios' }).status).toBe('unverified');
registerCollector(root, 'ios', { pid, startedAt: new Date().toISOString() });
expect(verifyCollectorOwnership({ pid, root, platform: 'ios' }).status).toBe('unverified');
registerCollector(root, 'ios', { pid, processToken: 'invalid' });
expect(verifyCollectorOwnership({ pid, root, platform: 'ios' }).status).toBe('unverified');
const parsed = decode(processToken!);
if (!parsed.ok) throw new Error(parsed.error.message);
const identity = parsed.value;
const start = identity.startTime.split(':');
start[0] = String(BigInt(start[0]!) + 1n);
const replacement =
'upid1.' + Buffer.from(JSON.stringify({ ...identity, startTime: start.join(':') })).toString('base64url');
registerCollector(root, 'ios', { pid, processToken: replacement });
expect(verifyCollectorOwnership({ pid, root, platform: 'ios' })).toEqual({ status: 'gone' });
unregisterCollector(root, 'ios', pid, processToken!);
expect(readCollectors(root).ios).toEqual({ pid, processToken: replacement });
registerCollector(root, 'ios', { pid, processToken });
expect(
verifyCollectorOwnership({ pid: 111, platform: 'ios', root: ROOT, isAlive: alive, readArgs: () => null }).status,
verifyCollectorOwnership({ pid, root, platform: 'ios', expected: { pid, processToken: replacement } }).status,
).toBe('unverified');
expect(
verifyCollectorOwnership({
pid: 111,
platform: 'ios',
root: ROOT,
isAlive: alive,
readArgs: () => {
throw new Error('ps failed');
},
}).status,
).toBe('unverified');
});

test('a pid that died between the checks is gone, not a refusal to report', () => {
expect(
verifyCollectorOwnership({ pid: 111, platform: 'ios', root: ROOT, isAlive: dead, readArgs: () => null }),
).toEqual({ status: 'gone' });
expect(
verifyCollectorOwnership({
pid: 111,
platform: 'ios',
root: ROOT,
isAlive: dead,
readArgs: () => ['/usr/bin/node', 'server.js'],
}),
).toEqual({ status: 'gone' });
});
child.kill('SIGTERM');
await exited;
expect(verifyCollectorOwnership({ pid, root, platform: 'ios' })).toEqual({ status: 'gone' });
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
await exited;
}
});
30 changes: 15 additions & 15 deletions packages/stim-cli/src/__tests__/collector-run.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert';
import { spawn, type ChildProcess } from 'node:child_process';
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { realpathSync, chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -28,7 +28,7 @@ let running: ChildProcess[] = [];
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'stim-test-'));
process.env.STIM_HOME = tmpHome;
root = mkdtempSync(join(tmpdir(), 'stim-ws-'));
root = realpathSync(mkdtempSync(join(tmpdir(), 'stim-ws-')));
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'ws' }));
shimDir = mkdtempSync(join(tmpdir(), 'stim-shim-'));
running = [];
Expand Down Expand Up @@ -239,35 +239,35 @@ describe('Contract 5: the registration', () => {
});

test('two platforms coexist, and unregistering one leaves the other', () => {
registerCollector(root, 'ios', { pid: 1, startedAt: 'a' });
registerCollector(root, 'android', { pid: 2, startedAt: 'b' });
registerCollector(root, 'ios', { pid: 1, startedAt: 'a', processToken: 'token' });
registerCollector(root, 'android', { pid: 2, startedAt: 'b', processToken: 'token' });
expect(Object.keys(readCollectors(root)).toSorted()).toEqual(['android', 'ios']);
unregisterCollector(root, 'ios', 1);
expect(readCollectors(root)).toEqual({ android: { pid: 2, startedAt: 'b' } });
unregisterCollector(root, 'ios', 1, 'token');
expect(readCollectors(root)).toEqual({ android: { pid: 2, startedAt: 'b', processToken: 'token' } });
});

test('the last collector out removes the key entirely rather than leaving an empty object', () => {
writeWorkspaceState(root, { supervisor: { pid: 123 } });
registerCollector(root, 'ios', { pid: 1, startedAt: 'a' });
unregisterCollector(root, 'ios', 1);
registerCollector(root, 'ios', { pid: 1, startedAt: 'a', processToken: 'token' });
unregisterCollector(root, 'ios', 1, 'token');
expect('collectors' in state()).toBe(false);
expect(state().supervisor).toEqual({ pid: 123 });
});

test('unregistering a platform that was never registered is a no-op', () => {
registerCollector(root, 'android', { pid: 2, startedAt: 'b' });
unregisterCollector(root, 'ios', 999);
expect(readCollectors(root)).toEqual({ android: { pid: 2, startedAt: 'b' } });
registerCollector(root, 'android', { pid: 2, startedAt: 'b', processToken: 'token' });
unregisterCollector(root, 'ios', 999, 'token');
expect(readCollectors(root)).toEqual({ android: { pid: 2, startedAt: 'b', processToken: 'token' } });
});

test('a stale unregister from a replaced collector does not clobber the newer registration', () => {
// #182: a replaced collector (proven unverified) is left running instead of signalled, so
// it can still reach its own finish() -> unregisterCollector after a newer collector has
// registered over the same platform key. Its own pid must not match the current record.
registerCollector(root, 'ios', { pid: 1, startedAt: 'a' });
registerCollector(root, 'ios', { pid: 2, startedAt: 'b' });
unregisterCollector(root, 'ios', 1);
expect(readCollectors(root)).toEqual({ ios: { pid: 2, startedAt: 'b' } });
registerCollector(root, 'ios', { pid: 1, startedAt: 'a', processToken: 'token' });
registerCollector(root, 'ios', { pid: 2, startedAt: 'b', processToken: 'token' });
unregisterCollector(root, 'ios', 1, 'token');
expect(readCollectors(root)).toEqual({ ios: { pid: 2, startedAt: 'b', processToken: 'token' } });
});
});

Expand Down
Loading
Loading