livedesk 0.1.459 → 0.1.461

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,9 +11,15 @@ The `hub/`, `client/`, `runtime-core/`, and `web/` folders in this package are
11
11
  generated copies. Edit the source packages and run
12
12
  `node packages/livedesk/scripts/sync-web-dist.js` before building or packing
13
13
  the product. The published `livedesk` package owns both runtime roles; the
14
- source-only `@livedesk/client` workspace is not a runtime dependency.
15
-
16
- ## Unified launch
14
+ source-only `@livedesk/client` workspace is not a runtime dependency.
15
+
16
+ LiveDesk's top runtime contract is transmission performance together with
17
+ long-running stability. A faster path is not accepted if it permits duplicate
18
+ capture helpers, stale monitor generations, unbounded frame queues, or an
19
+ unproven Direct/P2P/relay transition. Release builds run the same lifecycle,
20
+ transport recovery, and browser H.264 population gates used by CI.
21
+
22
+ ## Unified launch
17
23
 
18
24
  ```powershell
19
25
  npx -y --prefer-online livedesk@latest
@@ -90,9 +96,26 @@ npx -y --prefer-online livedesk@latest diagnose mac-physical --report ./mac-resu
90
96
  Use `diagnose mac-physical --help` to inspect requirements without starting a
91
97
  runtime or changing cached role/account state.
92
98
 
99
+ ## Physical transport diagnostic
100
+
101
+ While the local Hub is running and at least one Client is sending a current
102
+ Mode 3 H.264 stream, run:
103
+
104
+ ```bash
105
+ npx -y --prefer-online livedesk@latest diagnose transport-physical
106
+ ```
107
+
108
+ The diagnostic proves one unchanged stream identity through Direct TCP,
109
+ encrypted UDP P2P, encrypted relay, and Direct recovery. Every phase requires
110
+ an independently decodable SPS/PPS/IDR frame observed by the Hub within a
111
+ bounded deadline. It uses an exact-owner temporary lease, changes no firewall
112
+ or router settings, and writes a redacted report under
113
+ `~/.livedesk/diagnostics`. Use `diagnose transport-physical --help` for
114
+ device, Hub URL, timeout, and report-path options.
115
+
93
116
  ## Frame modes
94
-
95
- - Mode 2: independent 320x180 RGB565+LZO tiles for large, stable walls.
117
+
118
+ - Mode 2: independent 320x180 RGB565+LZO tiles for large, stable walls.
96
119
  - Mode 3: direct hardware H.264 for focused remote control up to 4K.
97
120
  - Mode 4: client Mode 2 inputs composited by an isolated Hub worker into one
98
121
  bounded H.264 Atlas stream, then split by UV into the normal browser slots.
package/bin/livedesk.js CHANGED
@@ -33,7 +33,11 @@ import {
33
33
  } from '../bootstrap/client-process-identity.js';
34
34
  import { runLegacyClientUpdateSupervisorCli } from '../bootstrap/legacy-client-update.js';
35
35
  import { transferSavedClientSessionToHub } from '../bootstrap/hub-auth-handoff.js';
36
- import { readWindowsOwnedProcessManifest } from '../client/src/runtime/windows-owned-process-manifest.js';
36
+ import {
37
+ consumeDeadWindowsOwnedProcessManifest,
38
+ readDeadWindowsOwnedProcessManifest,
39
+ readWindowsOwnedProcessManifest
40
+ } from '../client/src/runtime/windows-owned-process-manifest.js';
37
41
  import { inspectMacDiagnosticLease } from '../diagnostics/livedesk-mac-physical-isolation.mjs';
38
42
 
39
43
  const require = createRequire(import.meta.url);
@@ -1012,6 +1016,57 @@ async function exactWindowsRecordsStillRunning(records) {
1012
1016
  return records.filter(record => sameExactWindowsProcess(currentByPid.get(record.pid), record));
1013
1017
  }
1014
1018
 
1019
+ async function exactReclaimedWindowsRecordsStillRunning(records) {
1020
+ const targets = (Array.isArray(records) ? records : [])
1021
+ .map(record => normalizeExactWindowsProcessRecord(record, record?.depth))
1022
+ .filter(Boolean);
1023
+ if (targets.length === 0) return [];
1024
+ const targetPids = [...new Set(targets.map(record => record.pid))];
1025
+ const script = [
1026
+ '$ErrorActionPreference = "Stop";',
1027
+ `$targetPids = @(${targetPids.join(',')});`,
1028
+ '$records = @();',
1029
+ 'foreach ($targetPidValue in $targetPids) {',
1030
+ ' $targetPid = [int]$targetPidValue;',
1031
+ ' $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,ParentProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
1032
+ ' if (-not $target) { continue };',
1033
+ ' $records += [pscustomobject]@{',
1034
+ ' ProcessId = [int]$target.ProcessId;',
1035
+ ' ParentProcessId = [int]$target.ParentProcessId;',
1036
+ ' CreationUtcTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" }',
1037
+ ' };',
1038
+ '}',
1039
+ '[pscustomobject]@{ Records = $records } | ConvertTo-Json -Compress -Depth 4'
1040
+ ].join(' ');
1041
+ const result = await runQuiet(
1042
+ 'powershell.exe',
1043
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1044
+ { timeout: 10000, killSignal: 'SIGKILL' }
1045
+ );
1046
+ if (!result.ok || !result.stdout) {
1047
+ throw new Error(
1048
+ 'LiveDesk could not revalidate the reclaimed Windows process identities: '
1049
+ + `${result.stderr || result.error?.message || 'targeted CIM query failed'}`
1050
+ );
1051
+ }
1052
+ let parsed;
1053
+ try {
1054
+ parsed = JSON.parse(result.stdout);
1055
+ } catch (error) {
1056
+ throw new Error(`LiveDesk could not parse reclaimed Windows process identities: ${error.message}`);
1057
+ }
1058
+ const values = parsed?.Records == null
1059
+ ? []
1060
+ : Array.isArray(parsed.Records) ? parsed.Records : [parsed.Records];
1061
+ const currentByPid = new Map(
1062
+ values
1063
+ .map(item => normalizeExactWindowsProcessRecord(item))
1064
+ .filter(Boolean)
1065
+ .map(record => [record.pid, record])
1066
+ );
1067
+ return targets.filter(record => sameExactWindowsProcess(currentByPid.get(record.pid), record));
1068
+ }
1069
+
1015
1070
  async function waitForExactWindowsRecordsExit(records, timeoutMs = 2500) {
1016
1071
  const deadline = Date.now() + timeoutMs;
1017
1072
  let remaining = records;
@@ -1061,6 +1116,124 @@ async function terminateExactWindowsProcesses(recordOrRecords) {
1061
1116
  );
1062
1117
  }
1063
1118
 
1119
+ function normalizeReclaimedWindowsClientOwner(value) {
1120
+ const pid = Number(value?.pid || 0);
1121
+ const ownerToken = String(value?.ownerToken || '').trim();
1122
+ const ownerInstanceMarker = String(value?.ownerInstanceMarker || '').trim();
1123
+ const ownerStartOrder = String(value?.ownerStartOrder || '').trim();
1124
+ if (String(value?.role || '').trim().toLowerCase() !== 'client'
1125
+ || !Number.isInteger(pid)
1126
+ || pid <= 1
1127
+ || !/^[a-f0-9]{32}$/i.test(ownerToken)
1128
+ || !ownerInstanceMarker.startsWith(`${pid}:`)
1129
+ || !/^\d+$/.test(ownerStartOrder)) {
1130
+ return null;
1131
+ }
1132
+ return { pid, ownerToken, ownerInstanceMarker, ownerStartOrder };
1133
+ }
1134
+
1135
+ async function observeExactWindowsRecordsEmptyTwice(records) {
1136
+ let emptyObservations = 0;
1137
+ while (emptyObservations < 2) {
1138
+ const remaining = await exactReclaimedWindowsRecordsStillRunning(records);
1139
+ if (remaining.length > 0) {
1140
+ return { confirmed: false, remaining, emptyObservations };
1141
+ }
1142
+ emptyObservations += 1;
1143
+ if (emptyObservations < 2) {
1144
+ await waitMilliseconds(PORT_CLEANUP_POLL_MS);
1145
+ }
1146
+ }
1147
+ return { confirmed: true, remaining: [], emptyObservations };
1148
+ }
1149
+
1150
+ async function drainReclaimedWindowsClientManifest(reclaimedOwner, {
1151
+ maxAttempts = 4
1152
+ } = {}) {
1153
+ if (os.platform() !== 'win32') return { handled: false, reason: 'not-windows' };
1154
+ const exactOwner = normalizeReclaimedWindowsClientOwner(reclaimedOwner);
1155
+ if (!exactOwner) {
1156
+ return { handled: false, reason: 'legacy-or-incomplete-owner' };
1157
+ }
1158
+
1159
+ const inheritedManifest = readDeadWindowsOwnedProcessManifest({
1160
+ stateDir: MANAGER_STATE_DIR,
1161
+ owner: exactOwner
1162
+ });
1163
+ if (!inheritedManifest.ok) {
1164
+ if (inheritedManifest.missing === true) {
1165
+ if (process.env.LIVEDESK_DEBUG === '1') {
1166
+ console.log(
1167
+ `[LiveDesk] Reclaimed Windows Client generation ${exactOwner.pid} `
1168
+ + 'has no exact process manifest; continuing legacy cleanup.'
1169
+ );
1170
+ }
1171
+ return { handled: false, reason: 'manifest-missing' };
1172
+ }
1173
+ throw new Error(
1174
+ 'The reclaimed Windows Client process manifest was preserved because '
1175
+ + `its exact owner, timestamp, or records could not be verified: ${
1176
+ inheritedManifest.error?.message || 'manifest validation failed'
1177
+ }`
1178
+ );
1179
+ }
1180
+
1181
+ const tracked = inheritedManifest.records
1182
+ .map(record => normalizeExactWindowsProcessRecord(record, record?.depth))
1183
+ .filter(Boolean);
1184
+ if (tracked.some(record => record.pid === process.pid)) {
1185
+ throw new Error(
1186
+ 'The reclaimed Windows Client process manifest unexpectedly named the '
1187
+ + 'new launcher; every recorded process was preserved.'
1188
+ );
1189
+ }
1190
+
1191
+ const attempts = Math.max(1, Number(maxAttempts) || 4);
1192
+ let lastTerminationError = null;
1193
+ for (let attempt = 0; attempt <= attempts; attempt += 1) {
1194
+ const observation = await observeExactWindowsRecordsEmptyTwice(tracked);
1195
+ if (observation.confirmed) {
1196
+ const consumedManifest = consumeDeadWindowsOwnedProcessManifest({
1197
+ stateDir: MANAGER_STATE_DIR,
1198
+ owner: exactOwner,
1199
+ revision: inheritedManifest.revision
1200
+ });
1201
+ if (!consumedManifest.ok) {
1202
+ throw new Error(
1203
+ 'The reclaimed Windows Client process manifest was preserved after '
1204
+ + `drain because its exact revision could not be consumed safely: ${
1205
+ consumedManifest.error?.message || 'manifest consumption failed'
1206
+ }`
1207
+ );
1208
+ }
1209
+ if (process.env.LIVEDESK_DEBUG === '1') {
1210
+ console.log(
1211
+ `[LiveDesk] Reclaimed Windows Client generation ${exactOwner.pid} `
1212
+ + `drained ${tracked.length} exact process record(s) and consumed its manifest.`
1213
+ );
1214
+ }
1215
+ return { handled: true, records: tracked, consumedManifest };
1216
+ }
1217
+ if (attempt === attempts) break;
1218
+
1219
+ const descendantFirst = [...observation.remaining].sort((left, right) => (
1220
+ Number(right.depth || 0) - Number(left.depth || 0)
1221
+ ));
1222
+ const result = await terminateExactWindowsProcesses(descendantFirst);
1223
+ if (!result.ok) {
1224
+ lastTerminationError = result.error || new Error(result.stderr || 'termination failed');
1225
+ }
1226
+ await waitMilliseconds(PORT_CLEANUP_POLL_MS);
1227
+ }
1228
+
1229
+ const finalRemaining = await exactReclaimedWindowsRecordsStillRunning(tracked);
1230
+ throw new Error(
1231
+ 'The reclaimed Windows Client generation retained exact pid(s): '
1232
+ + `${finalRemaining.map(record => record.pid).join(', ') || 'unknown'}`
1233
+ + `${lastTerminationError?.message ? ` (${lastTerminationError.message})` : ''}`
1234
+ );
1235
+ }
1236
+
1064
1237
  async function terminateExactWindowsProcessTree(rootDetails, {
1065
1238
  label = 'LiveDesk process tree',
1066
1239
  maxAttempts = 4,
@@ -2947,14 +3120,6 @@ async function main() {
2947
3120
  await runLegacyClientUpdateSupervisorCli();
2948
3121
  return;
2949
3122
  }
2950
- const roleStateOptions = {
2951
- stateDir: MANAGER_STATE_DIR,
2952
- legacyClientStateDir: process.env.LIVEDESK_LEGACY_CLIENT_STATE_DIR
2953
- };
2954
- migrateLegacyClientState({
2955
- stateDir: MANAGER_STATE_DIR,
2956
- legacyClientStateDir: process.env.LIVEDESK_LEGACY_CLIENT_STATE_DIR
2957
- });
2958
3123
  const topLevel = parseTopLevelArgs(rawArgs);
2959
3124
  if (topLevel.debug) {
2960
3125
  process.env.LIVEDESK_DEBUG = '1';
@@ -2969,10 +3134,37 @@ async function main() {
2969
3134
  console.log(readVersion());
2970
3135
  return;
2971
3136
  }
2972
- await assertNoActiveMacPhysicalDiagnosticLease();
2973
3137
  const explicitRoleCommand = command === 'hub' || command === 'manager' || command === 'client';
2974
- let resolvedRole;
2975
- let runtimeArgs = args;
3138
+ if (explicitRoleCommand) {
3139
+ const commandArgs = args.slice(1).map(value => String(value || '').trim().toLowerCase());
3140
+ if (commandArgs.some(value => value === '--help' || value === '-h' || value === 'help')) {
3141
+ if (command === 'client') {
3142
+ console.warn('[LiveDesk] Legacy client command detected. Use `npx -y --prefer-online livedesk@latest` for automatic role selection.');
3143
+ printClientHelp();
3144
+ } else {
3145
+ printHelp();
3146
+ }
3147
+ return;
3148
+ }
3149
+ if (commandArgs.some(value => value === '--version' || value === '-v' || value === 'version')) {
3150
+ if (command === 'client') {
3151
+ console.warn('[LiveDesk] Legacy client command detected. Use `npx -y --prefer-online livedesk@latest` for automatic role selection.');
3152
+ }
3153
+ console.log(readVersion());
3154
+ return;
3155
+ }
3156
+ }
3157
+ await assertNoActiveMacPhysicalDiagnosticLease();
3158
+ const roleStateOptions = {
3159
+ stateDir: MANAGER_STATE_DIR,
3160
+ legacyClientStateDir: process.env.LIVEDESK_LEGACY_CLIENT_STATE_DIR
3161
+ };
3162
+ migrateLegacyClientState({
3163
+ stateDir: MANAGER_STATE_DIR,
3164
+ legacyClientStateDir: process.env.LIVEDESK_LEGACY_CLIENT_STATE_DIR
3165
+ });
3166
+ let resolvedRole;
3167
+ let runtimeArgs = args;
2976
3168
  if (explicitRoleCommand) {
2977
3169
  const role = command === 'client' ? 'client' : 'hub';
2978
3170
  resolvedRole = await resolveDeviceRole({ forcedRole: role, ...roleStateOptions });
@@ -3076,6 +3268,21 @@ async function main() {
3076
3268
  }
3077
3269
 
3078
3270
  if (resolvedRole.role === 'client') {
3271
+ // A launcher and Agent can both die while an independently spawned FFmpeg
3272
+ // survives. Before any new Client process starts, consume only the exact
3273
+ // manifest generation returned by the stale-lock compare-and-swap. No
3274
+ // process-name scan participates in this recovery path.
3275
+ const reclaimedManifestDrain = await drainReclaimedWindowsClientManifest(lock.reclaimedOwner);
3276
+ const testHoldAfterDrainMs = Number(
3277
+ process.env.LIVEDESK_TEST_HOLD_AFTER_RECLAIMED_WINDOWS_DRAIN_MS || 0
3278
+ );
3279
+ if (reclaimedManifestDrain.handled
3280
+ && process.env.LIVEDESK_DEBUG === '1'
3281
+ && Number.isFinite(testHoldAfterDrainMs)
3282
+ && testHoldAfterDrainMs > 0) {
3283
+ console.log('[LiveDesk test] Holding after reclaimed manifest drain before Client startup.');
3284
+ await waitMilliseconds(Math.min(60_000, Math.round(testHoldAfterDrainMs)));
3285
+ }
3079
3286
  const clientRuntimePort = normalizePort(
3080
3287
  process.env.LIVEDESK_CLIENT_RUNTIME_PORT || process.env.LIVEDESK_LOCAL_RUNTIME_PORT,
3081
3288
  DEFAULT_MANAGER_HTTP_PORT
@@ -3091,20 +3298,12 @@ async function main() {
3091
3298
  }
3092
3299
 
3093
3300
  if (command === 'client') {
3094
- if (args.slice(1).some(arg => arg === '--help' || arg === '-h' || arg === 'help')) {
3095
- printClientHelp();
3096
- return;
3097
- }
3098
3301
  await runClient(runtimeArgs, resolvedRole, lock);
3099
- return;
3100
- }
3101
- if (command === 'hub' || command === 'manager') {
3102
- if (args.slice(1).some(arg => arg === '--help' || arg === '-h' || arg === 'help')) {
3103
- printHelp();
3104
- return;
3105
- }
3302
+ return;
3303
+ }
3304
+ if (command === 'hub' || command === 'manager') {
3106
3305
  await runManager(runtimeArgs, resolvedRole, lock);
3107
- return;
3306
+ return;
3108
3307
  }
3109
3308
  if (resolvedRole.role === 'client') {
3110
3309
  await runClient(runtimeArgs, resolvedRole, lock);
@@ -300,6 +300,7 @@ export function acquireRuntimeLock({
300
300
  acquired: true,
301
301
  path,
302
302
  payload,
303
+ reclaimedOwner: null,
303
304
  release,
304
305
  updateRole: nextRole => updateOwnedLockRole(path, payload, nextRole)
305
306
  };
@@ -352,10 +353,15 @@ export function acquireRuntimeLock({
352
353
  released = true;
353
354
  releaseOwnedLock(path, payload);
354
355
  };
356
+ // Preserve the exact generation that this compare-and-swap replaced.
357
+ // Windows Client startup uses only this immutable tuple to consume the
358
+ // prior generation's exact PID + CreationDate child manifest.
359
+ const reclaimedOwner = Object.freeze({ ...existing });
355
360
  return {
356
361
  acquired: true,
357
362
  path,
358
363
  payload,
364
+ reclaimedOwner,
359
365
  release,
360
366
  updateRole: nextRole => updateOwnedLockRole(path, payload, nextRole)
361
367
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.213",
3
+ "version": "0.1.214",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,9 +10,10 @@
10
10
  "livedesk-client-fast": "bin/livedesk-client-fast.js"
11
11
  },
12
12
  "files": [
13
- "bin/",
14
- "src/",
15
- "README.md",
13
+ "bin/",
14
+ "src/",
15
+ "tests/",
16
+ "README.md",
16
17
  "THIRD_PARTY_NOTICES.md"
17
18
  ],
18
19
  "scripts": {
@@ -40,10 +41,10 @@
40
41
  "ws": "^8.18.3"
41
42
  },
42
43
  "optionalDependencies": {
43
- "@livedesk/fast-linux-x64": "0.1.417",
44
- "@livedesk/fast-osx-arm64": "0.1.417",
45
- "@livedesk/fast-osx-x64": "0.1.417",
46
- "@livedesk/fast-win-x64": "0.1.417"
44
+ "@livedesk/fast-linux-x64": "0.1.419",
45
+ "@livedesk/fast-osx-arm64": "0.1.419",
46
+ "@livedesk/fast-osx-x64": "0.1.419",
47
+ "@livedesk/fast-win-x64": "0.1.419"
47
48
  },
48
49
  "publishConfig": {
49
50
  "access": "public"
@@ -1,6 +1,7 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
2
  import {
3
3
  chmodSync,
4
+ linkSync,
4
5
  mkdirSync,
5
6
  readFileSync,
6
7
  renameSync,
@@ -85,6 +86,10 @@ function normalizeRecords(values) {
85
86
  return [...unique.values()];
86
87
  }
87
88
 
89
+ function sourceRevision(sourceText) {
90
+ return createHash('sha256').update(String(sourceText || ''), 'utf8').digest('hex');
91
+ }
92
+
88
93
  function writeJsonAtomic(path, value) {
89
94
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
90
95
  const temporaryPath = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
@@ -157,19 +162,16 @@ export function writeWindowsOwnedProcessManifest({
157
162
  }
158
163
  }
159
164
 
160
- /**
161
- * Reads only a fresh manifest belonging to the exact runtime-lock generation.
162
- * Owner mismatch, malformed identity, expiry, and PID reuse all fail closed.
163
- */
164
- export function readWindowsOwnedProcessManifest({
165
+ function readExactWindowsOwnedProcessManifest({
165
166
  stateDir,
166
167
  owner,
167
- maxAgeMs = DEFAULT_WINDOWS_OWNED_PROCESS_MANIFEST_MAX_AGE_MS,
168
+ maxAgeMs = null,
169
+ includeRevision = false,
168
170
  now
169
171
  } = {}) {
170
172
  const exactOwner = normalizeOwner(owner);
171
173
  const nowMs = resolveNow(now);
172
- const maximumAge = Number(maxAgeMs);
174
+ const maximumAge = maxAgeMs === null ? null : Number(maxAgeMs);
173
175
  const path = getWindowsOwnedProcessManifestPath(stateDir);
174
176
  if (!exactOwner) {
175
177
  return {
@@ -179,8 +181,8 @@ export function readWindowsOwnedProcessManifest({
179
181
  };
180
182
  }
181
183
  if (!Number.isFinite(nowMs)
182
- || !Number.isFinite(maximumAge)
183
- || maximumAge < 0) {
184
+ || (maximumAge !== null
185
+ && (!Number.isFinite(maximumAge) || maximumAge < 0))) {
184
186
  return {
185
187
  ok: false,
186
188
  records: [],
@@ -189,10 +191,17 @@ export function readWindowsOwnedProcessManifest({
189
191
  }
190
192
 
191
193
  let parsed;
194
+ let sourceText;
192
195
  try {
193
- parsed = JSON.parse(readFileSync(path, 'utf8'));
196
+ sourceText = readFileSync(path, 'utf8');
197
+ parsed = JSON.parse(sourceText);
194
198
  } catch (error) {
195
- return { ok: false, records: [], error };
199
+ return {
200
+ ok: false,
201
+ records: [],
202
+ missing: error?.code === 'ENOENT',
203
+ error
204
+ };
196
205
  }
197
206
  const updatedAt = String(parsed?.updatedAt || '');
198
207
  const updatedAtMs = Date.parse(updatedAt);
@@ -214,7 +223,7 @@ export function readWindowsOwnedProcessManifest({
214
223
  }
215
224
  if (!Number.isFinite(updatedAtMs)
216
225
  || updatedAtMs > nowMs + MAX_FUTURE_CLOCK_SKEW_MS
217
- || nowMs - updatedAtMs > maximumAge) {
226
+ || (maximumAge !== null && nowMs - updatedAtMs > maximumAge)) {
218
227
  return {
219
228
  ok: false,
220
229
  records: [],
@@ -231,5 +240,163 @@ export function readWindowsOwnedProcessManifest({
231
240
  error: resultError('Windows LiveDesk process manifest contains an invalid process identity.')
232
241
  };
233
242
  }
234
- return { ok: true, records: exactRecords, updatedAt };
243
+ return {
244
+ ok: true,
245
+ records: exactRecords,
246
+ updatedAt,
247
+ ...(includeRevision ? { revision: sourceRevision(sourceText) } : {})
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Reads only a fresh manifest belonging to the exact runtime-lock generation.
253
+ * Owner mismatch, malformed identity, expiry, and PID reuse all fail closed.
254
+ */
255
+ export function readWindowsOwnedProcessManifest({
256
+ stateDir,
257
+ owner,
258
+ maxAgeMs = DEFAULT_WINDOWS_OWNED_PROCESS_MANIFEST_MAX_AGE_MS,
259
+ now
260
+ } = {}) {
261
+ return readExactWindowsOwnedProcessManifest({
262
+ stateDir,
263
+ owner,
264
+ maxAgeMs,
265
+ now
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Recovers only the exact manifest generation returned by a successful
271
+ * stale-lock compare-and-swap. Its age is intentionally unbounded because a
272
+ * machine can be restarted long after the launcher and Agent died. Malformed,
273
+ * mismatched, or future-dated manifests still fail closed.
274
+ */
275
+ export function readDeadWindowsOwnedProcessManifest({
276
+ stateDir,
277
+ owner,
278
+ now
279
+ } = {}) {
280
+ return readExactWindowsOwnedProcessManifest({
281
+ stateDir,
282
+ owner,
283
+ maxAgeMs: null,
284
+ includeRevision: true,
285
+ now
286
+ });
287
+ }
288
+
289
+ /**
290
+ * Atomically quarantines and consumes only the exact dead-owner manifest
291
+ * revision that was drained. If another generation replaces the canonical
292
+ * path between validation and rename, the moved file is restored or preserved
293
+ * instead of ever being deleted.
294
+ */
295
+ export function consumeDeadWindowsOwnedProcessManifest({
296
+ stateDir,
297
+ owner,
298
+ revision,
299
+ now
300
+ } = {}) {
301
+ const exactOwner = normalizeOwner(owner);
302
+ const expectedRevision = String(revision || '').trim().toLowerCase();
303
+ const nowMs = resolveNow(now);
304
+ const path = getWindowsOwnedProcessManifestPath(stateDir);
305
+ if (!exactOwner || !/^[a-f0-9]{64}$/.test(expectedRevision)) {
306
+ return {
307
+ ok: false,
308
+ error: resultError('An exact Windows manifest owner and revision are required for consumption.')
309
+ };
310
+ }
311
+ if (!Number.isFinite(nowMs)) {
312
+ return { ok: false, error: resultError('A valid manifest consumption time is required.') };
313
+ }
314
+
315
+ const current = readDeadWindowsOwnedProcessManifest({
316
+ stateDir,
317
+ owner: exactOwner,
318
+ now: nowMs
319
+ });
320
+ if (!current.ok) {
321
+ return current.missing === true
322
+ ? { ok: true, path, consumed: false, missing: true }
323
+ : { ...current, path, consumed: false };
324
+ }
325
+ if (current.revision !== expectedRevision) {
326
+ return {
327
+ ok: false,
328
+ path,
329
+ consumed: false,
330
+ error: resultError('Windows LiveDesk process manifest changed after its exact process set was drained.')
331
+ };
332
+ }
333
+
334
+ const quarantinePath = `${path}.consume.${process.pid}.${randomBytes(8).toString('hex')}`;
335
+ try {
336
+ renameSync(path, quarantinePath);
337
+ } catch (error) {
338
+ return { ok: false, path, quarantinePath, consumed: false, error };
339
+ }
340
+
341
+ let movedSourceText = '';
342
+ let movedParsed = null;
343
+ try {
344
+ movedSourceText = readFileSync(quarantinePath, 'utf8');
345
+ movedParsed = JSON.parse(movedSourceText);
346
+ } catch {
347
+ // The exact post-rename validation below restores or preserves this
348
+ // file; unreadable content is never evidence that deletion is safe.
349
+ }
350
+ const movedUpdatedAtMs = Date.parse(String(movedParsed?.updatedAt || ''));
351
+ const movedRecords = normalizeRecords(movedParsed?.records);
352
+ const movedIsExact = movedParsed?.protocolVersion === WINDOWS_OWNED_PROCESS_MANIFEST_VERSION
353
+ && ownersMatch(movedParsed?.owner, exactOwner)
354
+ && Number.isFinite(movedUpdatedAtMs)
355
+ && movedUpdatedAtMs <= nowMs + MAX_FUTURE_CLOCK_SKEW_MS
356
+ && movedRecords !== null
357
+ && sourceRevision(movedSourceText) === expectedRevision;
358
+ if (!movedIsExact) {
359
+ let restored = false;
360
+ let restoreError = null;
361
+ try {
362
+ // Hard-link publication is create-if-absent. Unlike rename, it
363
+ // can never overwrite a newer canonical generation that appears
364
+ // during restoration.
365
+ linkSync(quarantinePath, path);
366
+ restored = true;
367
+ try { unlinkSync(quarantinePath); } catch { /* both exact links safely preserve the file */ }
368
+ } catch (error) {
369
+ restoreError = error;
370
+ }
371
+ return {
372
+ ok: false,
373
+ path,
374
+ quarantinePath: restored ? undefined : quarantinePath,
375
+ consumed: false,
376
+ error: resultError(
377
+ 'Windows LiveDesk process manifest changed during consumption; '
378
+ + `${restored ? 'the moved generation was restored.' : 'the moved generation was preserved.'}`
379
+ + `${restoreError?.message ? ` ${restoreError.message}` : ''}`
380
+ )
381
+ };
382
+ }
383
+
384
+ try {
385
+ unlinkSync(quarantinePath);
386
+ return {
387
+ ok: true,
388
+ path,
389
+ revision: expectedRevision,
390
+ records: movedRecords,
391
+ consumed: true
392
+ };
393
+ } catch (error) {
394
+ return {
395
+ ok: false,
396
+ path,
397
+ quarantinePath,
398
+ consumed: false,
399
+ error
400
+ };
401
+ }
235
402
  }