Skip to content

Commit b204c65

Browse files
authored
fix: fingerprint daemon runtime graph (#361)
1 parent 80e1bb8 commit b204c65

4 files changed

Lines changed: 133 additions & 34 deletions

File tree

src/daemon-client.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
type DaemonTransportPreference,
2323
} from './daemon/config.ts';
2424
import { uploadArtifact } from './upload-client.ts';
25+
import { computeDaemonCodeSignature } from './daemon/code-signature.ts';
26+
export { computeDaemonCodeSignature } from './daemon/code-signature.ts';
2527
export type DaemonRequest = SharedDaemonRequest;
2628
export type DaemonResponse = SharedDaemonResponse;
2729

@@ -734,19 +736,6 @@ function resolveLocalDaemonCodeSignature(): string {
734736
return computeDaemonCodeSignature(entryPath, launchSpec.root);
735737
}
736738

737-
export function computeDaemonCodeSignature(
738-
entryPath: string,
739-
root: string = findProjectRoot(),
740-
): string {
741-
try {
742-
const stat = fs.statSync(entryPath);
743-
const relativePath = path.relative(root, entryPath) || entryPath;
744-
return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
745-
} catch {
746-
return 'unknown';
747-
}
748-
}
749-
750739
async function sendRequest(
751740
info: DaemonInfo,
752741
req: DaemonRequest,

src/daemon/code-signature.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import crypto from 'node:crypto';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { findProjectRoot } from '../utils/version.ts';
5+
6+
const STATIC_IMPORT_RE =
7+
/(?:^|[^\w$.])(?:import|export)\s+(?:type\s+)?(?:[^'"`]*?\s+from\s+)?['"]([^'"]+)['"]/gm;
8+
const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/gm;
9+
const RESOLVABLE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] as const;
10+
11+
export function resolveDaemonCodeSignature(): string {
12+
const entryPath = process.argv[1];
13+
if (!entryPath) return 'unknown';
14+
return computeDaemonCodeSignature(entryPath);
15+
}
16+
17+
export function computeDaemonCodeSignature(
18+
entryPath: string,
19+
root: string = findProjectRoot(),
20+
): string {
21+
try {
22+
const normalizedRoot = path.resolve(root);
23+
const normalizedEntryPath = path.resolve(entryPath);
24+
const queue = [normalizedEntryPath];
25+
const visited = new Set<string>();
26+
const fingerprintParts: string[] = [];
27+
28+
while (queue.length > 0) {
29+
const currentPath = queue.pop();
30+
if (!currentPath || visited.has(currentPath)) continue;
31+
visited.add(currentPath);
32+
33+
const stat = fs.statSync(currentPath);
34+
if (!stat.isFile()) continue;
35+
36+
const relativePath = path.relative(normalizedRoot, currentPath) || currentPath;
37+
fingerprintParts.push(`${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`);
38+
39+
const content = fs.readFileSync(currentPath, 'utf8');
40+
for (const specifier of collectRelativeImportSpecifiers(content)) {
41+
const dependencyPath = resolveRelativeImportPath(currentPath, specifier);
42+
if (dependencyPath) {
43+
queue.push(dependencyPath);
44+
}
45+
}
46+
}
47+
48+
const fingerprint = fingerprintParts.sort().join('|');
49+
const hash = crypto.createHash('sha1').update(fingerprint).digest('hex');
50+
return `graph:${fingerprintParts.length}:${hash}`;
51+
} catch {
52+
return 'unknown';
53+
}
54+
}
55+
56+
function collectRelativeImportSpecifiers(content: string): string[] {
57+
const specifiers = new Set<string>();
58+
collectImportMatches(content, STATIC_IMPORT_RE, specifiers);
59+
collectImportMatches(content, DYNAMIC_IMPORT_RE, specifiers);
60+
return [...specifiers];
61+
}
62+
63+
function collectImportMatches(content: string, pattern: RegExp, specifiers: Set<string>): void {
64+
pattern.lastIndex = 0;
65+
let match: RegExpExecArray | null = null;
66+
while ((match = pattern.exec(content)) !== null) {
67+
const specifier = match[1]?.trim();
68+
if (specifier?.startsWith('.')) {
69+
specifiers.add(specifier);
70+
}
71+
}
72+
}
73+
74+
function resolveRelativeImportPath(fromPath: string, specifier: string): string | null {
75+
const basePath = path.resolve(path.dirname(fromPath), specifier);
76+
const direct = resolveExistingFile(basePath);
77+
if (direct) return direct;
78+
79+
for (const extension of RESOLVABLE_EXTENSIONS) {
80+
const withExtension = resolveExistingFile(`${basePath}${extension}`);
81+
if (withExtension) return withExtension;
82+
}
83+
84+
for (const extension of RESOLVABLE_EXTENSIONS) {
85+
const indexPath = resolveExistingFile(path.join(basePath, `index${extension}`));
86+
if (indexPath) return indexPath;
87+
}
88+
89+
return null;
90+
}
91+
92+
function resolveExistingFile(candidatePath: string): string | null {
93+
try {
94+
return fs.statSync(candidatePath).isFile() ? candidatePath : null;
95+
} catch {
96+
return null;
97+
}
98+
}

src/daemon/server-lifecycle.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'node:fs';
2-
import path from 'node:path';
3-
import { findProjectRoot, readVersion } from '../utils/version.ts';
2+
import { readVersion } from '../utils/version.ts';
43
import { isAgentDeviceDaemonProcess, readProcessStartTime } from '../utils/process-identity.ts';
4+
import { resolveDaemonCodeSignature } from './code-signature.ts';
55

66
export type DaemonLockInfo = {
77
pid: number;
@@ -10,19 +10,6 @@ export type DaemonLockInfo = {
1010
processStartTime?: string;
1111
};
1212

13-
export function resolveDaemonCodeSignature(): string {
14-
const entryPath = process.argv[1];
15-
if (!entryPath) return 'unknown';
16-
try {
17-
const stat = fs.statSync(entryPath);
18-
const root = findProjectRoot();
19-
const relativePath = path.relative(root, entryPath) || entryPath;
20-
return `${relativePath}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
21-
} catch {
22-
return 'unknown';
23-
}
24-
}
25-
2613
export function writeInfo(
2714
baseDir: string,
2815
infoPath: string,
@@ -129,4 +116,4 @@ export function parseIntegerEnv(raw: string | undefined): number | undefined {
129116
return value;
130117
}
131118

132-
export { readVersion, readProcessStartTime };
119+
export { readVersion, readProcessStartTime, resolveDaemonCodeSignature };

src/utils/__tests__/daemon-client.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,14 +1032,39 @@ test('downloadRemoteArtifact times out stalled artifact responses and removes pa
10321032
}
10331033
});
10341034

1035-
test('computeDaemonCodeSignature includes relative path, size, and mtime', () => {
1035+
test('computeDaemonCodeSignature fingerprints the daemon runtime import graph', () => {
10361036
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-signature-'));
10371037
try {
1038-
const daemonEntryPath = path.join(root, 'dist', 'src', 'daemon.js');
1038+
const daemonEntryPath = path.join(root, 'src', 'daemon.ts');
1039+
const helperPath = path.join(root, 'src', 'helper.ts');
1040+
const lazyPath = path.join(root, 'src', 'lazy.ts');
1041+
const ignoredPath = path.join(root, 'src', 'ignored.ts');
10391042
fs.mkdirSync(path.dirname(daemonEntryPath), { recursive: true });
1040-
fs.writeFileSync(daemonEntryPath, 'console.log("daemon");\n', 'utf8');
1041-
const signature = computeDaemonCodeSignature(daemonEntryPath, root);
1042-
assert.match(signature, /^dist\/src\/daemon\.js:\d+:\d+$/);
1043+
fs.writeFileSync(
1044+
daemonEntryPath,
1045+
[
1046+
"import './helper.ts';",
1047+
'export async function boot() {',
1048+
" return await import('./lazy.ts');",
1049+
'}',
1050+
'',
1051+
].join('\n'),
1052+
'utf8',
1053+
);
1054+
fs.writeFileSync(helperPath, 'export const helper = 1;\n', 'utf8');
1055+
fs.writeFileSync(lazyPath, 'export const lazy = 1;\n', 'utf8');
1056+
fs.writeFileSync(ignoredPath, 'export const ignored = 1;\n', 'utf8');
1057+
1058+
const initial = computeDaemonCodeSignature(daemonEntryPath, root);
1059+
assert.match(initial, /^graph:3:[0-9a-f]{40}$/);
1060+
1061+
fs.writeFileSync(lazyPath, 'export const lazy = 200;\n', 'utf8');
1062+
const changedRuntime = computeDaemonCodeSignature(daemonEntryPath, root);
1063+
assert.notEqual(changedRuntime, initial);
1064+
1065+
fs.writeFileSync(ignoredPath, 'export const ignored = 200;\n', 'utf8');
1066+
const changedUnrelated = computeDaemonCodeSignature(daemonEntryPath, root);
1067+
assert.equal(changedUnrelated, changedRuntime);
10431068
} finally {
10441069
fs.rmSync(root, { recursive: true, force: true });
10451070
}

0 commit comments

Comments
 (0)