From 12b915bd499b834dc63f88e19750b41945fb7459 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:38:40 -0700 Subject: [PATCH 01/11] Bind Node telemetry host services Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- sdk/node/src/bindings/telemetry.ts | 180 +++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 sdk/node/src/bindings/telemetry.ts diff --git a/sdk/node/src/bindings/telemetry.ts b/sdk/node/src/bindings/telemetry.ts new file mode 100644 index 000000000..2b4a0e23b --- /dev/null +++ b/sdk/node/src/bindings/telemetry.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import koffi, { type KoffiFunc } from 'koffi'; +import { loadMxcFfi } from '../native-library.js'; +import { decodeString, nativeStatusError } from './native-error.js'; + +export const TELEMETRY_CONSENT_DECISION_NO = 0; +export const TELEMETRY_CONSENT_DECISION_YES = 1; +export const TELEMETRY_CONSENT_DECISION_DISMISSED = 2; +export const TELEMETRY_CONSENT_PRESENTER_ERROR = -1; + +export interface TelemetryConsentSnapshot { + consent: string; + statusJson: string; + policy: string; + needsPrompt: boolean; +} + +const TelemetryConsentPresenter = koffi.proto( + 'MxcNodeTelemetryConsentPresenter', + 'int32_t', + ['const char *', 'void *'], +); + +type StringOutFunction = KoffiFunc<(out: unknown[]) => number>; +type BoolOutFunction = KoffiFunc<(out: number[]) => number>; +type StringFreeFunction = KoffiFunc<(value: unknown) => void>; +type RequestConsentFunction = KoffiFunc<( + locale: string | null, + presenter: ((promptJson: string, context: unknown) => number) | null, + context: unknown | null, + out: unknown[], +) => number>; + +interface TelemetryApi { + getConsent: StringOutFunction; + getConsentStatus: StringOutFunction; + getPolicy: StringOutFunction; + needsConsentPrompt: BoolOutFunction; + withdrawConsent: StringOutFunction; + requestConsent: RequestConsentFunction; + stringFree: StringFreeFunction; +} + +function isNonNullPointer(value: unknown): boolean { + return value !== null && value !== undefined && value !== 0 && value !== 0n; +} + +function readRequiredString( + invoke: (out: unknown[]) => number, + stringFree: StringFreeFunction, + message: string, +): string { + const out = [null] as unknown[]; + const status = invoke(out); + if (status !== 0) { + throw nativeStatusError(status, {}, message); + } + try { + if (!isNonNullPointer(out[0])) { + throw new Error(`${message}: native call returned success without a payload`); + } + const value = decodeString(out[0]); + if (value === undefined) { + throw new Error(`${message}: native call returned an unreadable payload`); + } + return value; + } finally { + if (isNonNullPointer(out[0])) { + stringFree(out[0]); + } + } +} + +function readBoolean(invoke: BoolOutFunction, message: string): boolean { + const out = [0]; + const status = invoke(out); + if (status !== 0) { + throw nativeStatusError(status, {}, message); + } + return out[0] !== 0; +} + +function withTelemetryApi(action: (api: TelemetryApi) => T): T { + const native = loadMxcFfi(); + try { + const handle = native.handle; + const api: TelemetryApi = { + getConsent: handle.func( + 'mxc_telemetry_get_consent', + 'int32_t', + [koffi.out(koffi.pointer('char', 2))], + ) as StringOutFunction, + getConsentStatus: handle.func( + 'mxc_telemetry_get_consent_status', + 'int32_t', + [koffi.out(koffi.pointer('char', 2))], + ) as StringOutFunction, + getPolicy: handle.func( + 'mxc_telemetry_get_policy', + 'int32_t', + [koffi.out(koffi.pointer('char', 2))], + ) as StringOutFunction, + needsConsentPrompt: handle.func( + 'mxc_telemetry_needs_consent_prompt', + 'int32_t', + [koffi.out(koffi.pointer('int32_t'))], + ) as BoolOutFunction, + withdrawConsent: handle.func( + 'mxc_telemetry_withdraw_consent', + 'int32_t', + [koffi.out(koffi.pointer('char', 2))], + ) as StringOutFunction, + requestConsent: handle.func( + 'mxc_telemetry_request_consent', + 'int32_t', + ['const char *', koffi.pointer(TelemetryConsentPresenter), 'void *', koffi.out(koffi.pointer('char', 2))], + ) as RequestConsentFunction, + stringFree: handle.func('mxc_string_free', 'void', ['char *']) as StringFreeFunction, + }; + return action(api); + } finally { + native.handle.unload(); + } +} + +export function readTelemetryConsentSnapshot(): TelemetryConsentSnapshot { + return withTelemetryApi((api) => ({ + consent: readRequiredString( + (out) => api.getConsent(out), + api.stringFree, + 'reading telemetry consent failed', + ), + statusJson: readRequiredString( + (out) => api.getConsentStatus(out), + api.stringFree, + 'reading telemetry consent status failed', + ), + policy: readRequiredString( + (out) => api.getPolicy(out), + api.stringFree, + 'reading telemetry policy failed', + ), + needsPrompt: readBoolean( + api.needsConsentPrompt, + 'checking telemetry consent prompt eligibility failed', + ), + })); +} + +export function withdrawTelemetryConsentJson(): string { + return withTelemetryApi((api) => readRequiredString( + (out) => api.withdrawConsent(out), + api.stringFree, + 'withdrawing telemetry consent failed', + )); +} + +export function requestTelemetryConsentJson( + locale: string | undefined, + presenter: (promptJson: string) => number, +): string { + return withTelemetryApi((api) => readRequiredString( + (out) => api.requestConsent( + locale ?? null, + (promptJson) => { + try { + return presenter(promptJson); + } catch { + return TELEMETRY_CONSENT_PRESENTER_ERROR; + } + }, + null, + out, + ), + api.stringFree, + 'requesting telemetry consent failed', + )); +} From 676f1165c2ae3611a140f4edf37bc64cb69e5f8f Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:37:07 -0700 Subject: [PATCH 02/11] Document telemetry binding role Identify the synchronous host-service boundary at the file introduction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- sdk/node/src/bindings/telemetry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/node/src/bindings/telemetry.ts b/sdk/node/src/bindings/telemetry.ts index 2b4a0e23b..9f36bf601 100644 --- a/sdk/node/src/bindings/telemetry.ts +++ b/sdk/node/src/bindings/telemetry.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Synchronous native bindings for telemetry consent and administrative policy. + import koffi, { type KoffiFunc } from 'koffi'; import { loadMxcFfi } from '../native-library.js'; import { decodeString, nativeStatusError } from './native-error.js'; From 48dd7bd94a7d1cfcc566d618f6ed4e9227cf3be5 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:39:00 -0700 Subject: [PATCH 03/11] Run Node telemetry consent on a worker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- .../src/bindings/telemetry-worker-entry.ts | 61 ++++++ sdk/node/src/bindings/telemetry-worker.ts | 193 ++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 sdk/node/src/bindings/telemetry-worker-entry.ts create mode 100644 sdk/node/src/bindings/telemetry-worker.ts diff --git a/sdk/node/src/bindings/telemetry-worker-entry.ts b/sdk/node/src/bindings/telemetry-worker-entry.ts new file mode 100644 index 000000000..ef81bfcf2 --- /dev/null +++ b/sdk/node/src/bindings/telemetry-worker-entry.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { parentPort, workerData } from 'node:worker_threads'; +import { MxcError } from '../errors.js'; +import { + readTelemetryConsentSnapshot, + requestTelemetryConsentJson, + withdrawTelemetryConsentJson, +} from './telemetry.js'; +import type { + TelemetryWorkerData, + TelemetryWorkerMessage, +} from './telemetry-worker.js'; + +function serializeError(error: unknown) { + if (error instanceof MxcError) { + return { + code: error.code, + message: error.message, + operation: error.operation, + nativeCode: error.nativeCode, + remediation: error.remediation, + details: error.details, + }; + } + return { + code: 'backend_error' as const, + message: error instanceof Error ? error.message : String(error), + }; +} + +const data = workerData as TelemetryWorkerData; +let message: TelemetryWorkerMessage; +try { + switch (data.operation) { + case 'query': + message = { kind: 'snapshot', snapshot: readTelemetryConsentSnapshot() }; + break; + case 'withdraw': + message = { kind: 'payload', payload: withdrawTelemetryConsentJson() }; + break; + case 'request': { + const decision = new Int32Array(data.decisionShared); + message = { + kind: 'payload', + payload: requestTelemetryConsentJson(data.locale, (promptJson) => { + Atomics.store(decision, 0, 0); + Atomics.store(decision, 1, 0); + parentPort!.postMessage({ kind: 'present', promptJson } satisfies TelemetryWorkerMessage); + Atomics.wait(decision, 0, 0); + return Atomics.load(decision, 1); + }), + }; + break; + } + } +} catch (error) { + message = { kind: 'error', error: serializeError(error) }; +} +parentPort!.postMessage(message); diff --git a/sdk/node/src/bindings/telemetry-worker.ts b/sdk/node/src/bindings/telemetry-worker.ts new file mode 100644 index 000000000..c29a3fedc --- /dev/null +++ b/sdk/node/src/bindings/telemetry-worker.ts @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Worker } from 'node:worker_threads'; +import { MxcError, type MxcErrorFields } from '../errors.js'; +import { + TELEMETRY_CONSENT_PRESENTER_ERROR, + type TelemetryConsentSnapshot, +} from './telemetry.js'; + +export interface TelemetryQueryWorkerData { + operation: 'query'; +} + +export interface TelemetryWithdrawWorkerData { + operation: 'withdraw'; +} + +export interface TelemetryRequestWorkerData { + operation: 'request'; + locale?: string; + decisionShared: SharedArrayBuffer; +} + +export type TelemetryWorkerData = + | TelemetryQueryWorkerData + | TelemetryWithdrawWorkerData + | TelemetryRequestWorkerData; + +export type TelemetryWorkerMessage = + | { kind: 'snapshot'; snapshot: TelemetryConsentSnapshot } + | { kind: 'payload'; payload: string } + | { kind: 'present'; promptJson: string } + | { kind: 'error'; error: MxcErrorFields }; + +export interface BindingTelemetryWorkerLike { + on(event: 'message', listener: (message: TelemetryWorkerMessage) => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'exit', listener: (code: number) => void): this; +} + +type WorkerFactory = (data: TelemetryWorkerData) => BindingTelemetryWorkerLike; + +const defaultWorkerFactory: WorkerFactory = (data) => new Worker( + new URL('./telemetry-worker-entry.js', import.meta.url), + { workerData: data, execArgv: [] }, +); + +let workerFactory = defaultWorkerFactory; + +export function _setBindingTelemetryWorkerFactory(factory?: WorkerFactory): void { + workerFactory = factory ?? defaultWorkerFactory; +} + +function serializeUnknownError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function runTelemetryWorker( + data: TelemetryWorkerData, + handleMessage: ( + message: TelemetryWorkerMessage, + finish: (action: () => void) => void, + resolve: (value: T) => void, + reject: (reason?: unknown) => void, + ) => void, +): Promise { + return new Promise((resolve, reject) => { + const worker = workerFactory(data); + let settled = false; + const finish = (action: () => void) => { + if (settled) { + return; + } + settled = true; + action(); + }; + + worker.on('message', (message) => handleMessage(message, finish, resolve, reject)); + worker.on('error', (error) => finish(() => reject(error))); + worker.on('exit', (code) => finish(() => reject(new MxcError({ + code: 'backend_error', + message: `mxc_telemetry worker exited before returning a result (code ${code})`, + })))); + }); +} + +export function runTelemetryConsentQueryAsync(): Promise { + return runTelemetryWorker({ operation: 'query' }, (message, finish, resolve, reject) => { + if (message.kind === 'snapshot') { + finish(() => resolve(message.snapshot)); + return; + } + if (message.kind === 'error') { + finish(() => reject(new MxcError(message.error))); + return; + } + finish(() => reject(new Error('telemetry query worker returned an unexpected message'))); + }); +} + +export function runTelemetryConsentWithdrawAsync(): Promise { + return runTelemetryWorker({ operation: 'withdraw' }, (message, finish, resolve, reject) => { + if (message.kind === 'payload') { + finish(() => resolve(message.payload)); + return; + } + if (message.kind === 'error') { + finish(() => reject(new MxcError(message.error))); + return; + } + finish(() => reject(new Error('telemetry withdrawal worker returned an unexpected message'))); + }); +} + +export function runTelemetryConsentRequestAsync( + locale: string | undefined, + presenter: (promptJson: string, signal: AbortSignal) => number | Promise, +): Promise { + const decision = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 2)); + return new Promise((resolve, reject) => { + const worker = workerFactory({ + operation: 'request', + locale, + decisionShared: decision.buffer as SharedArrayBuffer, + }); + let settled = false; + let presenterAbort: AbortController | null = null; + let presenterError: Error | undefined; + let decisionWritten = false; + + const writeDecision = (code: number): void => { + if (decisionWritten) { + return; + } + decisionWritten = true; + Atomics.store(decision, 1, code); + Atomics.store(decision, 0, 1); + Atomics.notify(decision, 0); + }; + + const finish = (action: () => void) => { + if (settled) { + return; + } + settled = true; + presenterAbort?.abort(); + if (!decisionWritten) { + writeDecision(TELEMETRY_CONSENT_PRESENTER_ERROR); + } + action(); + }; + + worker.on('message', (message) => { + if (message.kind === 'present') { + presenterAbort = new AbortController(); + void (async () => { + try { + const code = await presenter(message.promptJson, presenterAbort.signal); + if (!Number.isSafeInteger(code)) { + throw new Error(`consent presenter returned invalid decision '${String(code)}'`); + } + writeDecision(code); + } catch (error) { + presenterError = serializeUnknownError(error); + writeDecision(TELEMETRY_CONSENT_PRESENTER_ERROR); + } + })(); + return; + } + if (message.kind === 'payload') { + finish(() => { + if (presenterError) { + reject(presenterError); + } else { + resolve(message.payload); + } + }); + return; + } + if (message.kind === 'error') { + finish(() => reject(presenterError ?? new MxcError(message.error))); + return; + } + finish(() => reject(new Error('telemetry request worker returned an unexpected message'))); + }); + worker.on('error', (error) => finish(() => reject(error))); + worker.on('exit', (code) => finish(() => reject(presenterError ?? new MxcError({ + code: 'backend_error', + message: `mxc_telemetry worker exited before returning a result (code ${code})`, + })))); + }); +} From 0b18702e36f217efd400aee95cd649592787cabd Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:37:31 -0700 Subject: [PATCH 04/11] Document telemetry worker roles Explain the thread split and keep native symbol names out of user-facing failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- sdk/node/src/bindings/telemetry-worker-entry.ts | 2 ++ sdk/node/src/bindings/telemetry-worker.ts | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk/node/src/bindings/telemetry-worker-entry.ts b/sdk/node/src/bindings/telemetry-worker-entry.ts index ef81bfcf2..ebf60aa9f 100644 --- a/sdk/node/src/bindings/telemetry-worker-entry.ts +++ b/sdk/node/src/bindings/telemetry-worker-entry.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Worker-thread entry point for blocking telemetry persistence operations. + import { parentPort, workerData } from 'node:worker_threads'; import { MxcError } from '../errors.js'; import { diff --git a/sdk/node/src/bindings/telemetry-worker.ts b/sdk/node/src/bindings/telemetry-worker.ts index c29a3fedc..f1e36dd0e 100644 --- a/sdk/node/src/bindings/telemetry-worker.ts +++ b/sdk/node/src/bindings/telemetry-worker.ts @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Main-thread bridge for telemetry calls that block in the native runtime. +// Consent presentation remains on this thread while persistence runs in a worker. + import { Worker } from 'node:worker_threads'; import { MxcError, type MxcErrorFields } from '../errors.js'; import { @@ -80,7 +83,7 @@ function runTelemetryWorker( worker.on('error', (error) => finish(() => reject(error))); worker.on('exit', (code) => finish(() => reject(new MxcError({ code: 'backend_error', - message: `mxc_telemetry worker exited before returning a result (code ${code})`, + message: `telemetry worker exited before returning a result (code ${code})`, })))); }); } @@ -187,7 +190,7 @@ export function runTelemetryConsentRequestAsync( worker.on('error', (error) => finish(() => reject(error))); worker.on('exit', (code) => finish(() => reject(presenterError ?? new MxcError({ code: 'backend_error', - message: `mxc_telemetry worker exited before returning a result (code ${code})`, + message: `telemetry worker exited before returning a result (code ${code})`, })))); }); } From 4027dd17d9fd57936a9ab932d65d68b0f0ab5c6b Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:42:03 -0700 Subject: [PATCH 05/11] Test Node telemetry worker bridge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- .../default-consent-protocol-runner.test.ts | 865 +++--------------- 1 file changed, 121 insertions(+), 744 deletions(-) diff --git a/sdk/node/tests/unit/default-consent-protocol-runner.test.ts b/sdk/node/tests/unit/default-consent-protocol-runner.test.ts index 46e8dfb8a..25511a39f 100644 --- a/sdk/node/tests/unit/default-consent-protocol-runner.test.ts +++ b/sdk/node/tests/unit/default-consent-protocol-runner.test.ts @@ -1,799 +1,176 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Exercises the production protocol runner with controlled child-process I/O. - -import { describe, it, beforeEach, afterEach } from 'node:test'; +import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert'; import { EventEmitter } from 'node:events'; -import { readFileSync } from 'node:fs'; -import { PassThrough, type Readable, type Writable } from 'node:stream'; -import type { ChildProcess } from 'node:child_process'; import { - requestTelemetryConsent, - _setTelemetryPlatform, - _setTelemetryConsentChildFactory, - _setTelemetryConsentProtocolRunner, - _setTelemetryConsentTimeoutMs, - _resetTelemetryFailureReporting, - type TelemetryConsentPrompt, -} from '../../src/telemetry.js'; - -// A minimal ChildProcess-shaped fake wxc-exec that lets a test drive the -// stdout/stderr/exit sequence turn-by-turn. -interface FakeChild extends EventEmitter { - stdout: Readable; - stderr: Readable; - stdin: Writable; - kill(signal?: NodeJS.Signals | number): boolean; - killed: boolean; - killCount: number; - emitClose(code: number): void; - writeStdout(chunk: string): void; - writeStderr(chunk: string): void; - stdinChunks: string[]; - stdinEnded: boolean; -} - -function makeFakeChild(): FakeChild { - const emitter = new EventEmitter() as FakeChild; - emitter.stdout = new PassThrough(); - emitter.stderr = new PassThrough(); - emitter.stdin = new PassThrough(); - emitter.killed = false; - emitter.killCount = 0; - emitter.stdinChunks = []; - emitter.stdinEnded = false; - emitter.stdin.on('data', (chunk) => { - emitter.stdinChunks.push(chunk.toString('utf8')); - }); - emitter.stdin.on('end', () => { - emitter.stdinEnded = true; - }); - emitter.kill = (): boolean => { - emitter.killCount += 1; - emitter.killed = true; - (emitter.stdout as PassThrough).end(); - (emitter.stderr as PassThrough).end(); - return true; - }; - emitter.emitClose = (code: number): void => { - (emitter.stdout as PassThrough).end(); - (emitter.stderr as PassThrough).end(); - emitter.emit('close', code); - }; - emitter.writeStdout = (chunk: string): void => { - (emitter.stdout as PassThrough).write(chunk); - }; - emitter.writeStderr = (chunk: string): void => { - (emitter.stderr as PassThrough).write(chunk); - }; - return emitter; -} + _setBindingTelemetryWorkerFactory, + runTelemetryConsentQueryAsync, + runTelemetryConsentRequestAsync, + runTelemetryConsentWithdrawAsync, + type BindingTelemetryWorkerLike, + type TelemetryWorkerData, + type TelemetryWorkerMessage, +} from '../../src/bindings/telemetry-worker.js'; +import { + TELEMETRY_CONSENT_DECISION_YES, + TELEMETRY_CONSENT_PRESENTER_ERROR, +} from '../../src/bindings/telemetry.js'; -// The runner types the factory as returning a `ChildProcess`. Our fake covers -// only the subset the runner uses. Cast once here rather than everywhere. -function installFakeChildFactory(): { current: FakeChild; args: readonly string[] } { - const box: { current: FakeChild; args: readonly string[] } = { - current: null as unknown as FakeChild, - args: [], - }; - _setTelemetryConsentChildFactory((args) => { - box.args = args; - box.current = makeFakeChild(); - return box.current as unknown as ChildProcess; - }); - return box; -} +class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { + reply(message: TelemetryWorkerMessage): void { + queueMicrotask(() => this.emit('message', message)); + } -const prompt: TelemetryConsentPrompt = { - resourceVersion: 1, - locale: 'en-US', - title: { id: 'telemetry.consent.title', text: 'Help improve MXC' }, - body: { id: 'telemetry.consent.body', text: 'canonical body' }, - affirmativeLabel: { id: 'telemetry.consent.yes', text: 'Yes' }, - negativeLabel: { id: 'telemetry.consent.no', text: 'No' }, - learnMoreLabel: { id: 'telemetry.consent.learnMore', text: 'Learn more' }, - learnMoreUrl: 'https://example.microsoft.com/consent', -}; -const yesDecisionFixture = JSON.parse(readFileSync( - new URL('../../../../../tests/fixtures/telemetry-consent/presenter-decision-yes.json', import.meta.url), - 'utf8', -)) as Record; + fail(error: Error): void { + queueMicrotask(() => this.emit('error', error)); + } -function presentationLine(challenge = 'request-a'): string { - return `${JSON.stringify({ - action: 'request', - result: 'presentationRequired', - challenge, - prompt, - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: true, - policy: 'unrestricted', - reason: null, - })}\n`; + exit(code: number): void { + queueMicrotask(() => this.emit('exit', code)); + } } -function grantedLine(): string { - return `${JSON.stringify({ - action: 'request', - result: 'granted', - storedState: 'granted', - effectiveState: 'granted', - needsPrompt: false, - policy: 'unrestricted', - reason: null, - })}\n`; +function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { + const started = Date.now(); + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + if (condition()) { + clearInterval(timer); + resolve(); + return; + } + if (Date.now() - started > timeoutMs) { + clearInterval(timer); + reject(new Error(`condition did not become true within ${timeoutMs}ms`)); + } + }, 5); + }); } -async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise { - const start = Date.now(); - while (!predicate()) { - if (Date.now() - start > timeoutMs) { - throw new Error(`waitFor: predicate did not become true within ${timeoutMs}ms`); - } - await new Promise((r) => setTimeout(r, 5)); - } -} +describe('telemetry worker bindings', () => { + let worker: FakeWorker; + let workerData: TelemetryWorkerData | undefined; -describe('defaultConsentProtocolRunner (real code path)', () => { beforeEach(() => { - _setTelemetryPlatform('win32'); - // Ensure the DEFAULT runner is exercised, not one previously injected. - _setTelemetryConsentProtocolRunner(null); - _resetTelemetryFailureReporting(); + worker = new FakeWorker(); + workerData = undefined; + _setBindingTelemetryWorkerFactory((data) => { + workerData = data; + return worker; + }); }); afterEach(() => { - _setTelemetryPlatform(null); - _setTelemetryConsentChildFactory(null); - _setTelemetryConsentProtocolRunner(null); - _setTelemetryConsentTimeoutMs(null); - }); - - it('assembles a presentationRequired line split across multiple stdout chunks', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - assert.deepStrictEqual(box.args, [ - '--telemetry-consent', - 'request', - ]); - assert.ok(!box.args.includes('--config-base64')); - - const line = presentationLine(); - // Split the line in three fragments; the runner must accumulate them. - child.writeStdout(line.slice(0, 10)); - await new Promise((r) => setImmediate(r)); - child.writeStdout(line.slice(10, 40)); - await new Promise((r) => setImmediate(r)); - child.writeStdout(line.slice(40)); - - await waitFor(() => child.stdinEnded); - const echo = JSON.parse(child.stdinChunks.join('').trim()); - assert.strictEqual(echo.decision, 'yes'); - assert.deepStrictEqual(echo, yesDecisionFixture); - - child.writeStdout(grantedLine()); - child.emitClose(0); - const outcome = await promise; - assert.strictEqual(outcome.result, 'granted'); - assert.strictEqual(Object.hasOwn(outcome, 'challenge'), false); - assert.strictEqual(Object.hasOwn(outcome, 'prompt'), false); - }); - - it('echoes the native resource version even if the presenter mutates its prompt', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent((presentedPrompt) => { - presentedPrompt.resourceVersion = 99; - return 'yes'; + _setBindingTelemetryWorkerFactory(); + }); + + it('returns read-only consent snapshots from the worker', async () => { + const promise = runTelemetryConsentQueryAsync(); + worker.reply({ + kind: 'snapshot', + snapshot: { + consent: 'granted', + statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + policy: 'allowed', + needsPrompt: false, + }, }); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(presentationLine()); - await waitFor(() => child.stdinEnded); - const echo = JSON.parse(child.stdinChunks.join('').trim()); - assert.strictEqual(echo.resourceVersion, prompt.resourceVersion); - - child.writeStdout(grantedLine()); - child.emitClose(0); - assert.strictEqual((await promise).result, 'granted'); - }); - - it('reports successful native request diagnostics once', async () => { - const warnings: string[] = []; - const originalWarn = console.warn; - console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' ')); - try { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStderr('mxc: telemetry administrative policy failure\n'); - child.writeStdout(presentationLine()); - await waitFor(() => child.stdinEnded); - child.writeStdout(grantedLine()); - child.emitClose(0); - - assert.strictEqual((await promise).result, 'granted'); - } finally { - console.warn = originalWarn; - } - assert.deepStrictEqual(warnings, [ - 'mxc-sdk: requestTelemetryConsent native diagnostic: ' - + 'mxc: telemetry administrative policy failure', - ]); - }); - - it('passes the requested locale only on the dedicated request command', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'dismissed', 'fr-FR'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - assert.deepStrictEqual(box.args, [ - '--telemetry-consent', - 'request', - '--telemetry-consent-locale=fr-FR', - ]); - child.writeStdout(`${JSON.stringify({ - action: 'request', - result: 'dismissed', - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: true, - policy: 'unrestricted', - reason: null, - })}\n`); - child.emitClose(0); - assert.strictEqual((await promise).result, 'dismissed'); - }); - - it('rejects multiple terminal responses', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - - box.current.writeStdout(grantedLine() + grantedLine()); - - await assert.rejects(promise, /multiple terminal responses/); - assert.strictEqual(box.current.killCount, 1); - }); - - it('rejects a presentation after a terminal response', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - - box.current.writeStdout(grantedLine() + presentationLine()); - - await assert.rejects(promise, /presentation after its terminal response/); - assert.strictEqual(box.current.killCount, 1); - }); - - it('returns a presenter-unavailable terminal response', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - - box.current.writeStdout(`${JSON.stringify({ - action: 'request', - result: 'presentationUnavailable', - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: true, - policy: 'unrestricted', - reason: 'presentation-unavailable', - })}\n`); - box.current.writeStderr('mxc: host presenter unavailable'); - box.current.emitClose(1); - assert.deepStrictEqual(await promise, { - action: 'request', - result: 'presentationUnavailable', - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: true, - policy: 'unrestricted', + consent: 'granted', + statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + policy: 'allowed', + needsPrompt: false, }); - assert.strictEqual(box.current.killCount, 0); }); - it('rejects multiple presentations', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - - box.current.writeStdout(presentationLine() + presentationLine('request-b')); - - await assert.rejects(promise, /multiple presentations/); - assert.strictEqual(box.current.killCount, 1); - }); - - it('suspends the IO timeout while the presenter is thinking', async () => { - // The runner is documented to clear the IO timeout right before awaiting - // the presenter and rearm it after. A very short timeout would trip if - // the presenter's think time were counted toward it. - _setTelemetryConsentTimeoutMs(200); - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(async (): Promise<'yes'> => { - await new Promise((r) => setTimeout(r, 500)); - return 'yes'; + it('returns withdrawal payloads from the worker', async () => { + const promise = runTelemetryConsentWithdrawAsync(); + worker.reply({ + kind: 'payload', + payload: '{"result":"withdrawn","storedState":"denied","effectiveState":"denied","reason":null,"policy":"unrestricted"}', }); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(presentationLine()); - // The 500 ms sleep would trip a naive 200 ms timeout if suspend/resume - // were broken; we wait for the presenter echo instead. - await waitFor(() => child.stdinEnded, 3_000); - child.writeStdout(grantedLine()); - child.emitClose(0); - const outcome = await promise; - assert.strictEqual(outcome.result, 'granted'); - }); - - it('does not invoke the presenter when the IO deadline has already expired', async () => { - _setTelemetryConsentTimeoutMs(1_000); - const originalNow = Date.now; - let now = 1_000; - Date.now = () => now; - try { - const box = installFakeChildFactory(); - let presenterCalls = 0; - const promise = requestTelemetryConsent(() => { - presenterCalls += 1; - return 'yes'; - }); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - now = 2_000; - child.writeStdout(presentationLine()); - - await assert.rejects(promise, /timed out/); - assert.strictEqual(presenterCalls, 0); - assert.deepStrictEqual(child.stdinChunks, []); - assert.strictEqual(child.killCount, 1); - } finally { - Date.now = originalNow; - } + assert.strictEqual( + await promise, + '{"result":"withdrawn","storedState":"denied","effectiveState":"denied","reason":null,"policy":"unrestricted"}', + ); }); - it('does not re-arm the IO timeout from stdout or stderr while the presenter is active', async () => { - _setTelemetryConsentTimeoutMs(100); - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(async (): Promise<'yes'> => { - box.current.writeStderr('still presenting\n'); - box.current.writeStdout('\n'); - await new Promise((r) => setTimeout(r, 300)); - return 'yes'; + it('relays an async presenter decision through the shared callback buffer', async () => { + const promise = runTelemetryConsentRequestAsync('en-US', async (promptJson, signal) => { + assert.match(promptJson, /"locale":"en-US"/); + assert.strictEqual(signal.aborted, false); + await Promise.resolve(); + return TELEMETRY_CONSENT_DECISION_YES; }); - await new Promise((r) => setImmediate(r)); - const child = box.current; + await waitFor(() => workerData?.operation === 'request'); + const decision = new Int32Array((workerData as Extract).decisionShared); - child.writeStdout(presentationLine()); - await waitFor(() => child.stdinEnded, 3_000); - child.writeStdout(grantedLine()); - child.emitClose(0); - const outcome = await promise; - assert.strictEqual(outcome.result, 'granted'); - assert.strictEqual(child.killed, false); - }); - - it('rejects terminal output received before the presenter decision is written', async () => { - const box = installFakeChildFactory(); - let presenterStarted = false; - const promise = requestTelemetryConsent(() => { - presenterStarted = true; - return new Promise<'yes'>(() => {}); + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', }); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(presentationLine()); - await waitFor(() => presenterStarted); - child.writeStdout(grantedLine()); - await assert.rejects(promise, /before the presenter decision was written/); - assert.strictEqual(child.killCount, 1); - assert.deepStrictEqual(child.stdinChunks, []); - }); + await waitFor(() => Atomics.load(decision, 0) === 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_DECISION_YES); - it('rejects unterminated terminal output buffered before the presenter decision', async () => { - const box = installFakeChildFactory(); - let resolvePresenter!: (decision: 'no') => void; - const promise = requestTelemetryConsent( - () => new Promise<'no'>((resolve) => { - resolvePresenter = resolve; - }), - ); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(presentationLine()); - await waitFor(() => resolvePresenter !== undefined); - child.writeStdout(grantedLine().trimEnd()); - resolvePresenter('no'); - await waitFor(() => child.stdinEnded); - child.emitClose(0); - - await assert.rejects(promise, /before the presenter decision was written/); - const echo = JSON.parse(child.stdinChunks.join('').trim()); - assert.strictEqual(echo.decision, 'no'); - }); - - it('does not start a queued presenter after the child closes', async () => { - const box = installFakeChildFactory(); - let presenterCalls = 0; - const promise = requestTelemetryConsent(() => { - presenterCalls += 1; - return new Promise<'yes'>(() => {}); + worker.reply({ + kind: 'payload', + payload: '{"result":"granted","storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', }); - await new Promise((r) => setImmediate(r)); - const child = box.current; - child.writeStdout(presentationLine()); - child.emitClose(0); - - await assert.rejects(promise, /exited before presentation completed \(0\)/); - assert.strictEqual(presenterCalls, 0); - assert.deepStrictEqual(child.stdinChunks, []); + assert.strictEqual( + await promise, + '{"result":"granted","storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + ); }); - it('fails closed when the presenter throws and never writes a fallback decision', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => { + it('preserves the original presenter failure instead of the native fallback error', async () => { + const promise = runTelemetryConsentRequestAsync(undefined, () => { throw new Error('UI unavailable'); }); - const rejection = assert.rejects(promise, /UI unavailable/); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(presentationLine()); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - assert.deepStrictEqual(child.stdinChunks, []); - assert.strictEqual(child.stdinEnded, false); - }); + await waitFor(() => workerData?.operation === 'request'); + const decision = new Int32Array((workerData as Extract).decisionShared); - it('fails closed when a dynamically typed presenter returns an invalid decision', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'maybe' as unknown as 'yes'); - const rejection = assert.rejects(promise, /invalid decision 'maybe'/); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(presentationLine()); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - assert.deepStrictEqual(child.stdinChunks, []); - assert.strictEqual(child.stdinEnded, false); - }); - - it('rejects cleanly on a malformed presentation line and kills the child', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - const rejection = assert.rejects(promise); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout('this is not json at all\n'); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - assert.strictEqual(child.killCount, 1); - }); - - it('rejects a status response on the request protocol', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - const rejection = assert.rejects(promise, /unrecognised telemetry consent output/); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(`${JSON.stringify({ - action: 'status', - result: 'status', - storedState: 'granted', - effectiveState: 'granted', - needsPrompt: false, - policy: 'allowed', - reason: null, - })}\n`); - - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - assert.strictEqual(child.killCount, 1); - assert.deepStrictEqual(child.stdinChunks, []); - }); - - it('does not process queued lines after a protocol failure', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - const rejection = assert.rejects(promise); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(`not json\n${grantedLine()}`); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - assert.strictEqual(child.killCount, 1); - assert.deepStrictEqual(child.stdinChunks, []); - }); - - it('kills the child when stdin fails while replying to the presenter', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(async (): Promise<'yes'> => { - box.current.stdin.emit('error', new Error('stdin broke')); - return 'yes'; + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', }); - const rejection = assert.rejects(promise, /stdin broke/); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(presentationLine()); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - }); - - it('fails closed when the child stdout stream errors', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.stdout.emit('error', new Error('stdout broke')); - - await assert.rejects(promise, /stdout broke/); - assert.strictEqual(child.killed, true); - assert.strictEqual(child.killCount, 1); - }); - - it('fails closed when the child stderr stream errors', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.stderr.emit('error', new Error('stderr broke')); - - await assert.rejects(promise, /stderr broke/); - assert.strictEqual(child.killed, true); - assert.strictEqual(child.killCount, 1); - }); - - it('rejects cleanly when the child exits before responding', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStderr('boom\n'); - child.emitClose(2); - await assert.rejects(promise, /telemetry consent process failed \(2\)/); - }); - - it('fires the IO timeout when the child produces no output at all', async () => { - _setTelemetryConsentTimeoutMs(50); - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - // Do not write to stdout/stderr; the timeout must fire. - await assert.rejects(promise, /timed out/); - assert.strictEqual(child.killed, true); - }); - - it('does not let continuous stderr output extend the IO timeout', async () => { - _setTelemetryConsentTimeoutMs(50); - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - const interval = setInterval(() => child.writeStderr('diagnostic\n'), 10); - try { - await assert.rejects(promise, /timed out/); - } finally { - clearInterval(interval); - } - assert.strictEqual(child.killed, true); - }); - - it('does not let partial stdout keep the protocol alive past its deadline', async () => { - _setTelemetryConsentTimeoutMs(50); - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - const interval = setInterval(() => child.writeStdout('x'), 10); - try { - await assert.rejects(promise, /timed out/); - } finally { - clearInterval(interval); - } - assert.strictEqual(child.killed, true); - }); - - it('rejects stdout that exceeds the protocol buffer limit', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - child.writeStdout('x'.repeat(1024 * 1024 + 1)); + await waitFor(() => Atomics.load(decision, 0) === 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); - await assert.rejects(promise, /stdout limit/); - assert.strictEqual(child.killed, true); - }); - - it('rejects stderr that exceeds the diagnostic buffer limit', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStderr('x'.repeat(64 * 1024 + 1)); - - await assert.rejects(promise, /stderr limit/); - assert.strictEqual(child.killed, true); - }); - - it('rejects more than sixteen protocol lines and kills the child', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout('\n'.repeat(17)); - - await assert.rejects(promise, /protocol line limit/); - assert.strictEqual(child.killed, true); - }); - - it('counts an unterminated final line against the protocol limit', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(`${'\n'.repeat(16)}partial`); - child.emitClose(0); - - await assert.rejects(promise, /protocol line limit/); - assert.strictEqual(child.killed, true); - }); - - it('aborts a pending presenter when the child fails', async () => { - const box = installFakeChildFactory(); - let observedSignal: AbortSignal | undefined; - const promise = requestTelemetryConsent((_prompt, signal) => { - observedSignal = signal; - return new Promise<'yes'>(() => {}); + worker.reply({ + kind: 'error', + error: { code: 'backend_error', message: 'requesting telemetry consent failed' }, }); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(presentationLine()); - await waitFor(() => observedSignal !== undefined); - child.emitClose(2); - await assert.rejects(promise, /exited before presentation completed/); - assert.strictEqual(observedSignal?.aborted, true); + await assert.rejects(promise, /UI unavailable/); }); - it('aborts a pending presenter when the child exits successfully', async () => { - const box = installFakeChildFactory(); + it('aborts the presenter signal and wakes the native callback on worker exit', async () => { let observedSignal: AbortSignal | undefined; - const promise = requestTelemetryConsent((_prompt, signal) => { + const promise = runTelemetryConsentRequestAsync(undefined, async (_promptJson, signal) => { observedSignal = signal; - return new Promise<'yes'>(() => {}); + await new Promise(() => {}); + return TELEMETRY_CONSENT_DECISION_YES; }); - await new Promise((r) => setImmediate(r)); - const child = box.current; + await waitFor(() => workerData?.operation === 'request'); + const decision = new Int32Array((workerData as Extract).decisionShared); - child.writeStdout(presentationLine()); + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', + }); await waitFor(() => observedSignal !== undefined); - child.emitClose(0); - - await assert.rejects(promise, /exited before presentation completed \(0\)/); - assert.strictEqual(observedSignal?.aborted, true); - }); - it('kills the child on a presentation missing its challenge', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - const rejection = assert.rejects(promise); - await new Promise((r) => setImmediate(r)); - const child = box.current; + worker.exit(9); - child.writeStdout(`${JSON.stringify({ - action: 'request', - result: 'presentationRequired', - prompt, - // Missing `challenge`. - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: true, - policy: 'unrestricted', - reason: null, - })}\n`); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - }); - - it('kills the child on a presentation with a malformed prompt', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - const rejection = assert.rejects(promise); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(`${JSON.stringify({ - action: 'request', - result: 'presentationRequired', - prompt: { ...prompt, title: { id: 1, text: 'invalid' } }, - challenge: 'request-a', - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: true, - policy: 'unrestricted', - reason: null, - })}\n`); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - }); - - it('kills the child when a response omits its required reason field', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - const rejection = assert.rejects(promise, /unrecognised telemetry consent output/); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(`${JSON.stringify({ - action: 'request', - result: 'policyBlocked', - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: false, - policy: 'blocked', - })}\n`); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; - }); - - it('kills the child on an unknown status reason', async () => { - const box = installFakeChildFactory(); - const promise = requestTelemetryConsent(() => 'yes'); - const rejection = assert.rejects(promise); - await new Promise((r) => setImmediate(r)); - const child = box.current; - - child.writeStdout(`${JSON.stringify({ - action: 'request', - result: 'policyBlocked', - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: false, - reason: 'future-reason', - policy: 'blocked', - })}\n`); - await waitFor(() => child.killed); - child.emitClose(1); - await rejection; + await assert.rejects(promise, /worker exited before returning a result/); + assert.strictEqual(observedSignal?.aborted, true); + assert.strictEqual(Atomics.load(decision, 0), 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); }); }); From ba5c64fefafb1052c84460a6e6b40f209aa695c2 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:42:08 -0700 Subject: [PATCH 06/11] Move Node telemetry consent onto mxc_ffi Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- sdk/node/README.md | 6 +- sdk/node/src/telemetry.ts | 644 +++++++++----------------------------- 2 files changed, 149 insertions(+), 501 deletions(-) diff --git a/sdk/node/README.md b/sdk/node/README.md index 4b146ec82..e86cbfc1b 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -157,7 +157,7 @@ The default `processcontainer`, `bubblewrap`, `lxc`, `seatbelt`, `wslc`, and `is > **Hyperlight** is an opt-in build flavor (Linux x64 and Windows x64) gated by the `--with-hyperlight` cargo feature. Default shipped binaries do not include it; build from source with `build.bat --with-hyperlight` (Windows) or the equivalent cargo invocation on Linux. -`getPlatformSupport()` reports backend availability and, when the native probe can determine it, `uiCapabilities`: a platform-neutral view of which UI restrictions the host can enforce. This is currently populated only by the Windows native probe, where it is derived from `JOB_OBJECT_UILIMIT_*` support; Linux and macOS omit the field until their probes expose equivalent data. On Linux, `unavailableReasons` provides a diagnostic for each unavailable LXC or Bubblewrap backend even when the other backend keeps the platform supported. +`getPlatformSupport()` reports backend availability. Its `uiCapabilities` field is reserved for a future native host-services expansion and is currently omitted on all platforms. On Linux, `unavailableReasons` provides a diagnostic for each unavailable LXC or Bubblewrap backend even when the other backend keeps the platform supported. On Linux, when Bubblewrap is available, `getPlatformSupport()` also reports `bubblewrapNetwork`: whether this host can enforce **proxy-only egress** (schema `0.8.0-alpha`+ proxy mode, which runs the sandbox in a private network namespace and default-drops everything except the proxy). That mode has no fallback — a policy the host cannot satisfy fails rather than silently degrading — so check it before spawning: @@ -670,8 +670,8 @@ telemetry remains off. On non-Windows hosts requests and withdrawals return `notApplicable` without invoking the presenter. `queryTelemetryConsentAsync()` fails closed to `'undetermined'` rather than -`'granted'`. Its `error` field is present when the command fails or returns an -invalid response. A valid native fail-closed response can return +`'granted'`. Its `error` field is present when the native query fails or +returns an invalid response. A valid native fail-closed response can return `'undetermined'` or a blocked policy without `error`; any accompanying native diagnostic is reported once through `console.warn`: diff --git a/sdk/node/src/telemetry.ts b/sdk/node/src/telemetry.ts index 775c63288..9c1df8e80 100644 --- a/sdk/node/src/telemetry.ts +++ b/sdk/node/src/telemetry.ts @@ -1,8 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { execFile, spawn } from 'node:child_process'; -import { findWxcExecutable } from './platform.js'; +import { + TELEMETRY_CONSENT_DECISION_DISMISSED, + TELEMETRY_CONSENT_DECISION_NO, + TELEMETRY_CONSENT_DECISION_YES, + type TelemetryConsentSnapshot, +} from './bindings/telemetry.js'; +import { + runTelemetryConsentQueryAsync, + runTelemetryConsentRequestAsync, + runTelemetryConsentWithdrawAsync, +} from './bindings/telemetry-worker.js'; const TELEMETRY_CONSENT_STATES = ['granted', 'denied', 'undetermined', 'not-applicable'] as const; const TELEMETRY_POLICY_STATES = ['unrestricted', 'allowed', 'blocked', 'not-applicable'] as const; @@ -28,22 +37,12 @@ const CONSENT_STATUS_REASONS = [ 'presentation-unavailable', 'not-applicable', ] as const; -// Protocol-only results: never a successful outcome for a caller. -const CONSENT_PROTOCOL_ONLY_RESULTS = [ - 'status', - 'presentationRequired', -] as const; -const CONSENT_PROTOCOL_RESULTS = [ - ...CONSENT_PROTOCOL_ONLY_RESULTS, - ...TELEMETRY_CONSENT_RESULTS, -] as const; export type TelemetryConsentState = (typeof TELEMETRY_CONSENT_STATES)[number]; export type TelemetryPolicyState = (typeof TELEMETRY_POLICY_STATES)[number]; export type TelemetryConsentDecision = (typeof TELEMETRY_CONSENT_DECISIONS)[number]; export type TelemetryConsentResult = (typeof TELEMETRY_CONSENT_RESULTS)[number]; type ConsentStatusReason = (typeof CONSENT_STATUS_REASONS)[number]; -type TelemetryConsentProtocolResult = (typeof CONSENT_PROTOCOL_RESULTS)[number]; export interface TelemetryConsentMessage { id: string; @@ -70,15 +69,6 @@ export interface TelemetryConsentOutcome { needsPrompt: boolean; } -interface TelemetryConsentProtocolResponse - extends Omit { - action: ConsentAction; - result: TelemetryConsentProtocolResult; - reason: ConsentStatusReason | null; - prompt?: TelemetryConsentPrompt | null; - challenge?: string | null; -} - export type TelemetryConsentPresenter = ( prompt: TelemetryConsentPrompt, signal?: AbortSignal, @@ -93,380 +83,26 @@ export interface TelemetryConsentQuery { error?: string; } -interface ConsentCommandOutput { - stdout: string; - stderr: string; -} - -type ConsentAsyncRunner = (args: readonly string[]) => Promise; -type ConsentAction = 'request' | 'withdraw' | 'status'; -type ConsentProtocolRunner = ( - locale: string | undefined, - presenter: TelemetryConsentPresenter, -) => Promise; -type ConsentChildFactory = (args: readonly string[]) => ReturnType; - -const DEFAULT_CONSENT_REQUEST_TIMEOUT_MS = 30_000; -const MAX_CONSENT_STDOUT_BYTES = 1024 * 1024; -const MAX_CONSENT_STDERR_BYTES = 64 * 1024; -const MAX_CONSENT_PROTOCOL_LINES = 16; -let consentRequestTimeoutMs = DEFAULT_CONSENT_REQUEST_TIMEOUT_MS; -const defaultConsentChildFactory: ConsentChildFactory = (args) => - spawn(executable(), [...args], { - env: process.env, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); -let consentChildFactory: ConsentChildFactory = defaultConsentChildFactory; - -function maintenanceArgs(action: 'request' | 'withdraw' | 'status', locale?: string): string[] { - const args = ['--telemetry-consent', action]; - if (action === 'request') { - if (locale !== undefined) { - args.push(`--telemetry-consent-locale=${locale}`); - } - } - return args; -} - -function executable(): string { - const path = findWxcExecutable(); - if (!path) { - throw new Error('wxc-exec was not found; the MXC native binary is missing from this installation'); - } - return path; -} - -function defaultConsentAsyncRunner(args: readonly string[]): Promise { - return new Promise((resolve, reject) => { - execFile( - executable(), - [...args], - { - timeout: 5000, - encoding: 'utf-8', - windowsHide: true, - }, - (error, stdout, stderr) => { - if (error) { - reject(error); - } else { - resolve({ stdout, stderr }); - } - }, - ); - }); +interface TelemetryConsentStatusPayload { + storedState: TelemetryConsentState; + effectiveState: TelemetryConsentState; + policy: TelemetryPolicyState; + reason: ConsentStatusReason | null; } -async function defaultConsentProtocolRunner( - locale: string | undefined, - presenter: TelemetryConsentPresenter, -): Promise { - return new Promise((resolve, reject) => { - const child = consentChildFactory(maintenanceArgs('request', locale)); - const childStdin = child.stdin; - const childStdout = child.stdout; - const childStderr = child.stderr; - if (childStdin === null || childStdout === null || childStderr === null) { - child.kill(); - reject(new Error('telemetry consent process did not expose stdio pipes')); - return; - } - let stdout = ''; - let stderr = ''; - let finalResponse: TelemetryConsentOutcome | undefined; - let presentationSeen = false; - let presenterResponsePending = false; - let terminalSeen = false; - let settled = false; - let timeout: NodeJS.Timeout | null = null; - let timeoutStartedAt = 0; - let timeoutRemainingMs = consentRequestTimeoutMs; - let lineQueue: Promise = Promise.resolve(); - let childKilled = false; - let stdoutBytes = 0; - let stderrBytes = 0; - let protocolLines = 0; - let bufferedOutputReceivedWhilePresenterPending = false; - let childExitCode: number | null | undefined; - const presenterAbort = new AbortController(); - let rejectPresenterWait: ((error: Error) => void) | undefined; - - const clearProtocolDeadline = (): void => { - if (timeout !== null) { - clearTimeout(timeout); - timeout = null; - } - }; - const pauseProtocolDeadline = (): boolean => { - if (timeout !== null) { - timeoutRemainingMs -= Date.now() - timeoutStartedAt; - clearProtocolDeadline(); - } - if (timeoutRemainingMs <= 0) { - fail(new Error('telemetry consent request timed out')); - return false; - } - return true; - }; - const resumeProtocolDeadline = (): void => { - if (settled) { - return; - } - clearProtocolDeadline(); - if (timeoutRemainingMs <= 0) { - fail(new Error('telemetry consent request timed out')); - return; - } - timeoutStartedAt = Date.now(); - timeout = setTimeout(() => { - fail(new Error('telemetry consent request timed out')); - }, timeoutRemainingMs); - }; - - const fail = (error: unknown): void => { - if (!settled) { - settled = true; - clearProtocolDeadline(); - presenterAbort.abort(); - rejectPresenterWait?.( - error instanceof Error ? error : new Error(String(error)), - ); - rejectPresenterWait = undefined; - if (!childKilled) { - childKilled = true; - child.kill(); - } - reject(error instanceof Error ? error : new Error(String(error))); - } - }; - resumeProtocolDeadline(); - - const processResponse = async ( - response: TelemetryConsentProtocolResponse, - ): Promise => { - if (settled) { - return; - } - - if (response.result !== 'presentationRequired') { - if (terminalSeen) { - fail(new Error('telemetry consent protocol emitted multiple terminal responses')); - return; - } - terminalSeen = true; - finalResponse = toConsentOutcome(response); - return; - } - if (terminalSeen) { - fail(new Error('telemetry consent protocol emitted a presentation after its terminal response')); - return; - } - if (presentationSeen) { - fail(new Error('telemetry consent protocol emitted multiple presentations')); - return; - } - presentationSeen = true; - if (!isConsentPrompt(response.prompt) || !isChallenge(response.challenge)) { - fail(new Error('telemetry consent presentation omitted its prompt or challenge')); - return; - } - if (childExitCode !== undefined) { - fail(new Error( - `telemetry consent process exited before presentation completed (${childExitCode ?? 'no exit code'})`, - )); - return; - } - - const resourceVersion = response.prompt.resourceVersion; - let decision: TelemetryConsentDecision = 'dismissed'; - if (!pauseProtocolDeadline()) { - return; - } - try { - const processEnded = new Promise((_resolve, reject) => { - rejectPresenterWait = reject; - }); - decision = await Promise.race([ - Promise.resolve(presenter(response.prompt, presenterAbort.signal)), - processEnded, - ]); - if (!isDecision(decision)) { - throw new Error(`consent presenter returned invalid decision '${String(decision)}'`); - } - } catch (error) { - rejectPresenterWait = undefined; - fail(error); - return; - } finally { - rejectPresenterWait = undefined; - } - if (settled) { - return; - } - resumeProtocolDeadline(); - if (settled) { - return; - } - presenterResponsePending = false; - childStdin.write(`${JSON.stringify({ - challenge: response.challenge, - resourceVersion, - decision, - })}\n`); - childStdin.end(); - }; - - const receiveLine = ( - line: string, - receivedWhilePresenterPending = false, - ): void => { - if (settled) { - return; - } - protocolLines += 1; - if (protocolLines > MAX_CONSENT_PROTOCOL_LINES) { - fail(new Error('telemetry consent process exceeded the protocol line limit')); - return; - } - if (line.trim() === '') { - return; - } - - let response: TelemetryConsentProtocolResponse; - try { - response = parseMaintenanceResponse(line, 'request'); - } catch (error) { - fail(error); - return; - } - if ( - (receivedWhilePresenterPending || presenterResponsePending) - && response.result !== 'presentationRequired' - ) { - fail(new Error( - 'telemetry consent protocol emitted a terminal response before the presenter decision was written', - )); - return; - } - if (response.result === 'presentationRequired') { - presenterResponsePending = true; - } - lineQueue = lineQueue - .then(() => processResponse(response)) - .catch(fail); - }; - - childStdout.setEncoding('utf8'); - childStdout.on('data', (chunk: string) => { - if (settled) { - return; - } - stdoutBytes += Buffer.byteLength(chunk, 'utf8'); - if (stdoutBytes > MAX_CONSENT_STDOUT_BYTES) { - fail(new Error('telemetry consent process exceeded the stdout limit')); - return; - } - stdout += chunk; - const lines = stdout.split(/\r?\n/); - stdout = lines.pop() ?? ''; - const bufferedLineWasPremature = bufferedOutputReceivedWhilePresenterPending; - bufferedOutputReceivedWhilePresenterPending = false; - for (const [index, line] of lines.entries()) { - receiveLine(line, index === 0 && bufferedLineWasPremature); - } - if (lines.length === 0 && bufferedLineWasPremature) { - bufferedOutputReceivedWhilePresenterPending = true; - } - if (presenterResponsePending && stdout.trim() !== '') { - bufferedOutputReceivedWhilePresenterPending = true; - } - }); - childStdout.on('error', fail); - childStderr.setEncoding('utf8'); - childStderr.on('data', (chunk: string) => { - if (settled) { - return; - } - stderrBytes += Buffer.byteLength(chunk, 'utf8'); - if (stderrBytes > MAX_CONSENT_STDERR_BYTES) { - fail(new Error('telemetry consent process exceeded the stderr limit')); - return; - } - stderr += chunk; - }); - childStderr.on('error', fail); - childStdin.on('error', (error) => { - if (!settled) { - fail(error); - } - }); - child.on('error', fail); - child.on('close', (code) => { - childExitCode = code; - if (rejectPresenterWait !== undefined) { - presenterAbort.abort(); - rejectPresenterWait( - new Error(`telemetry consent process exited before presentation completed (${code ?? 'no exit code'})`), - ); - rejectPresenterWait = undefined; - } - void (async (): Promise => { - await lineQueue; - if (settled) return; - clearProtocolDeadline(); - if (stdout.trim() !== '') { - receiveLine(stdout, bufferedOutputReceivedWhilePresenterPending); - await lineQueue; - if (settled) return; - } - if (finalResponse === undefined) { - fail(new Error(`telemetry consent process failed (${code ?? 'no exit code'}): ${stderr.trim()}`)); - return; - } - const validTerminalExit = ( - (finalResponse.result === 'presentationUnavailable' && code === 1) - || (finalResponse.result !== 'presentationUnavailable' && code === 0) - ); - if (!validTerminalExit) { - fail(new Error(`telemetry consent process failed (${code ?? 'no exit code'}): ${stderr.trim()}`)); - return; - } - reportNativeDiagnostic('requestTelemetryConsent', stderr); - settled = true; - resolve(finalResponse); - })().catch(fail); - }); - }); -} - -let consentAsyncRunner: ConsentAsyncRunner = defaultConsentAsyncRunner; -let protocolRunner: ConsentProtocolRunner = defaultConsentProtocolRunner; +const MAX_REPORTED_FAILURE_CATEGORIES = 64; +const MAX_DIAGNOSTIC_LENGTH = 512; +const reportedFailureCategories = new Set(); let platformOverride: NodeJS.Platform | null = null; /** @internal Test-only. */ -export function _setTelemetryConsentAsyncRunner(runner: ConsentAsyncRunner | null): void { - consentAsyncRunner = runner ?? defaultConsentAsyncRunner; -} - -/** @internal Test-only. */ -export function _setTelemetryConsentProtocolRunner(runner: ConsentProtocolRunner | null): void { - protocolRunner = runner ?? defaultConsentProtocolRunner; -} - -/** @internal Test-only. */ -export function _setTelemetryConsentChildFactory(factory: ConsentChildFactory | null): void { - consentChildFactory = factory ?? defaultConsentChildFactory; -} - -/** @internal Test-only. */ -export function _setTelemetryConsentTimeoutMs(timeoutMs: number | null): void { - consentRequestTimeoutMs = timeoutMs ?? DEFAULT_CONSENT_REQUEST_TIMEOUT_MS; +export function _setTelemetryPlatform(platform: NodeJS.Platform | null): void { + platformOverride = platform; } /** @internal Test-only. */ -export function _setTelemetryPlatform(platform: NodeJS.Platform | null): void { - platformOverride = platform; +export function _resetTelemetryFailureReporting(): void { + reportedFailureCategories.clear(); } function isWindows(): boolean { @@ -517,35 +153,17 @@ function isConsentPrompt(value: unknown): value is TelemetryConsentPrompt { && typeof prompt.learnMoreUrl === 'string'; } -function isChallenge(value: unknown): value is string { - return typeof value === 'string' && value.length > 0; +function isRequestResult(value: unknown): value is TelemetryConsentResult { + return value === 'granted' + || value === 'denied' + || value === 'dismissed' + || value === 'alreadyGranted' + || value === 'policyBlocked' + || value === 'notApplicable'; } -function isResult(value: unknown): value is TelemetryConsentProtocolResult { - return includes(CONSENT_PROTOCOL_RESULTS, value); -} - -function isResultForAction( - action: ConsentAction, - result: TelemetryConsentProtocolResult, -): boolean { - switch (action) { - case 'status': - return result === 'status' || result === 'notApplicable'; - case 'withdraw': - return result === 'withdrawn' || result === 'notApplicable'; - case 'request': - return [ - 'presentationRequired', - 'granted', - 'denied', - 'dismissed', - 'alreadyGranted', - 'policyBlocked', - 'presentationUnavailable', - 'notApplicable', - ].includes(result); - } +function isWithdrawResult(value: unknown): value is TelemetryConsentResult { + return value === 'withdrawn' || value === 'notApplicable'; } function shouldPrompt( @@ -556,61 +174,97 @@ function shouldPrompt( && (policy === 'unrestricted' || policy === 'allowed'); } -function parseMaintenanceResponse( - stdout: string, - expectedAction: ConsentAction, -): TelemetryConsentProtocolResponse { - const parsed: unknown = JSON.parse(stdout); +function invalidTelemetryOutput(detail: string): Error { + return new Error(`unrecognised telemetry consent output: ${detail.trim().slice(0, 200)}`); +} + +function parseStatusPayload(json: string): TelemetryConsentStatusPayload { + const parsed: unknown = JSON.parse(json); if (parsed === null || typeof parsed !== 'object') { - throw new Error('unrecognised telemetry consent output'); + throw invalidTelemetryOutput(json); } const value = parsed as Record; if ( !isConsentState(value.storedState) || !isConsentState(value.effectiveState) || !isPolicyState(value.policy) - || !isResult(value.result) - || value.action !== expectedAction - || !isResultForAction(expectedAction, value.result) - || typeof value.needsPrompt !== 'boolean' - || value.needsPrompt !== shouldPrompt(value.effectiveState, value.policy) || !Object.hasOwn(value, 'reason') - || ( - value.reason !== null - && !isStatusReason(value.reason) - ) + || (value.reason !== null && !isStatusReason(value.reason)) ) { - throw new Error(`unrecognised telemetry consent output: ${stdout.trim().slice(0, 200)}`); + throw invalidTelemetryOutput(json); } - if ( - value.result === 'presentationRequired' - && (!isConsentPrompt(value.prompt) || !isChallenge(value.challenge)) - ) { - throw new Error(`unrecognised telemetry consent output: ${stdout.trim().slice(0, 200)}`); - } - return parsed as TelemetryConsentProtocolResponse; + return { + storedState: value.storedState, + effectiveState: value.effectiveState, + policy: value.policy, + reason: value.reason, + }; } -function toConsentOutcome(response: TelemetryConsentProtocolResponse): TelemetryConsentOutcome { - if ( - response.action === 'status' - || includes(CONSENT_PROTOCOL_ONLY_RESULTS, response.result) - ) { - throw new Error('unrecognised telemetry consent terminal output'); +function parseConsentOutcome( + json: string, + action: 'request' | 'withdraw', +): TelemetryConsentOutcome { + const parsed: unknown = JSON.parse(json); + if (parsed === null || typeof parsed !== 'object') { + throw invalidTelemetryOutput(json); + } + const value = parsed as Record; + const status = parseStatusPayload(json); + let result: TelemetryConsentResult; + if (action === 'request') { + if (!isRequestResult(value.result)) { + throw invalidTelemetryOutput(json); + } + result = value.result; + } else { + if (!isWithdrawResult(value.result)) { + throw invalidTelemetryOutput(json); + } + result = value.result; } return { - action: response.action, - result: response.result, - storedState: response.storedState, - effectiveState: response.effectiveState, - policy: response.policy, - needsPrompt: response.needsPrompt, + action, + result, + storedState: status.storedState, + effectiveState: status.effectiveState, + policy: status.policy, + needsPrompt: shouldPrompt(status.effectiveState, status.policy), }; } -const MAX_REPORTED_FAILURE_CATEGORIES = 64; -const MAX_DIAGNOSTIC_LENGTH = 512; -const reportedFailureCategories = new Set(); +function parseConsentPromptJson(promptJson: string): TelemetryConsentPrompt { + const parsed: unknown = JSON.parse(promptJson); + if (!isConsentPrompt(parsed)) { + throw invalidTelemetryOutput(promptJson); + } + return parsed; +} + +function decisionCode(decision: TelemetryConsentDecision): number { + switch (decision) { + case 'yes': + return TELEMETRY_CONSENT_DECISION_YES; + case 'no': + return TELEMETRY_CONSENT_DECISION_NO; + case 'dismissed': + return TELEMETRY_CONSENT_DECISION_DISMISSED; + } +} + +function validateSnapshot(snapshot: TelemetryConsentSnapshot): TelemetryConsentStatusPayload { + const status = parseStatusPayload(snapshot.statusJson); + if ( + !isConsentState(snapshot.consent) + || !isPolicyState(snapshot.policy) + || snapshot.consent !== status.effectiveState + || snapshot.policy !== status.policy + || snapshot.needsPrompt !== shouldPrompt(status.effectiveState, status.policy) + ) { + throw invalidTelemetryOutput(snapshot.statusJson); + } + return status; +} function tryRegisterFailureCategory(category: string): boolean { if ( @@ -634,34 +288,15 @@ function reportFailClosed(operation: string, safeResult: string, detail: string) try { const category = `${operation}:${safeResult}:failure`; if (tryRegisterFailureCategory(category)) { - const message = `mxc-sdk: ${operation} failed and is reporting '${safeResult}' to stay fail-closed: ${boundedDiagnostic(detail)}`; - console.warn(message); + console.warn( + `mxc-sdk: ${operation} failed and is reporting '${safeResult}' to stay fail-closed: ${boundedDiagnostic(detail)}`, + ); } } catch { // Reporting must not affect the fail-closed result. } } -function reportNativeDiagnostic(operation: string, stderr: string): void { - const detail = stderr.trim(); - if (detail === '') { - return; - } - try { - const category = `${operation}:native`; - if (tryRegisterFailureCategory(category)) { - console.warn(`mxc-sdk: ${operation} native diagnostic: ${boundedDiagnostic(detail)}`); - } - } catch { - // Reporting must not affect the native result. - } -} - -/** @internal Test-only. */ -export function _resetTelemetryFailureReporting(): void { - reportedFailureCategories.clear(); -} - function notApplicable(action: 'request' | 'withdraw'): TelemetryConsentOutcome { return { action, @@ -673,18 +308,6 @@ function notApplicable(action: 'request' | 'withdraw'): TelemetryConsentOutcome }; } -function consentQueryFromResponse( - response: TelemetryConsentProtocolResponse, -): TelemetryConsentQuery { - return { - state: response.effectiveState, - storedState: response.storedState, - effectiveState: response.effectiveState, - needsPrompt: shouldPrompt(response.effectiveState, response.policy), - policy: response.policy, - }; -} - function failedConsentQuery(operation: string, error: unknown): TelemetryConsentQuery { const detail = error instanceof Error ? error.message : String(error); reportFailClosed(operation, 'undetermined', detail); @@ -698,6 +321,25 @@ function failedConsentQuery(operation: string, error: unknown): TelemetryConsent }; } +function validateLocale(locale?: string): void { + if (locale?.includes('\0')) { + throw new Error('Telemetry consent locale cannot contain embedded NUL characters.'); + } +} + +async function presentConsentDecision( + presenter: TelemetryConsentPresenter, + promptJson: string, + signal: AbortSignal, +): Promise { + const prompt = parseConsentPromptJson(promptJson); + const decision = await presenter(prompt, signal); + if (!isDecision(decision)) { + throw new Error(`consent presenter returned invalid decision '${String(decision)}'`); + } + return decisionCode(decision); +} + /** Read persisted/effective consent and policy without blocking the event loop. */ export async function queryTelemetryConsentAsync(): Promise { if (!isWindows()) { @@ -710,11 +352,15 @@ export async function queryTelemetryConsentAsync(): Promise { + validateLocale(locale); if (!isWindows()) { return notApplicable('request'); } - return protocolRunner(locale, presenter); + const json = await runTelemetryConsentRequestAsync( + locale, + (promptJson, signal) => presentConsentDecision(presenter, promptJson, signal), + ); + return parseConsentOutcome(json, 'request'); } /** Idempotently withdraw telemetry consent without blocking the event loop. */ @@ -737,15 +388,12 @@ export async function withdrawTelemetryConsentAsync(): Promise Date: Mon, 14 Sep 2026 19:42:12 -0700 Subject: [PATCH 07/11] Test Node telemetry host services Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- sdk/node/tests/unit/telemetry.test.ts | 395 +++++++++++++------------- 1 file changed, 194 insertions(+), 201 deletions(-) diff --git a/sdk/node/tests/unit/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts index 7e254d8a9..167de01bd 100644 --- a/sdk/node/tests/unit/telemetry.test.ts +++ b/sdk/node/tests/unit/telemetry.test.ts @@ -1,18 +1,42 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, beforeEach, afterEach } from 'node:test'; +import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert'; +import { EventEmitter } from 'node:events'; + import { queryTelemetryConsentAsync, requestTelemetryConsent, withdrawTelemetryConsentAsync, - _setTelemetryConsentAsyncRunner, - _setTelemetryConsentProtocolRunner, - _setTelemetryPlatform, _resetTelemetryFailureReporting, + _setTelemetryPlatform, type TelemetryConsentPrompt, } from '../../src/telemetry.js'; +import { + _setBindingTelemetryWorkerFactory, + type BindingTelemetryWorkerLike, + type TelemetryWorkerData, + type TelemetryWorkerMessage, +} from '../../src/bindings/telemetry-worker.js'; +import { + TELEMETRY_CONSENT_DECISION_YES, + TELEMETRY_CONSENT_PRESENTER_ERROR, +} from '../../src/bindings/telemetry.js'; + +class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { + reply(message: TelemetryWorkerMessage): void { + queueMicrotask(() => this.emit('message', message)); + } + + fail(error: Error): void { + queueMicrotask(() => this.emit('error', error)); + } + + exit(code: number): void { + queueMicrotask(() => this.emit('exit', code)); + } +} const prompt: TelemetryConsentPrompt = { resourceVersion: 1, @@ -24,40 +48,50 @@ const prompt: TelemetryConsentPrompt = { learnMoreLabel: { id: 'telemetry.consent.learnMore', text: 'Privacy Statement' }, learnMoreUrl: 'https://go.microsoft.com/fwlink/?linkid=521839', }; +const promptJson = JSON.stringify(prompt); -function status( - effectiveState: 'granted' | 'denied' | 'undetermined' | 'not-applicable', - policy: 'unrestricted' | 'allowed' | 'blocked' | 'not-applicable' = 'unrestricted', - needsPrompt = false, -): string { - return JSON.stringify({ - action: 'status', - result: 'status', - storedState: effectiveState, - effectiveState, - needsPrompt, - policy, - reason: null, +function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { + const started = Date.now(); + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + if (condition()) { + clearInterval(timer); + resolve(); + return; + } + if (Date.now() - started > timeoutMs) { + clearInterval(timer); + reject(new Error(`condition did not become true within ${timeoutMs}ms`)); + } + }, 5); }); } -function commandOutput(stdout: string, stderr = ''): { stdout: string; stderr: string } { - return { stdout, stderr }; -} - describe('telemetry consent', () => { beforeEach(() => { _setTelemetryPlatform('win32'); }); afterEach(() => { - _setTelemetryConsentAsyncRunner(null); - _setTelemetryConsentProtocolRunner(null); + _setBindingTelemetryWorkerFactory(); _setTelemetryPlatform(null); }); - it('parses typed stored/effective status', async () => { - _setTelemetryConsentAsyncRunner(async () => commandOutput(status('granted', 'allowed'))); + it('parses typed stored/effective status from a consistent native snapshot', async () => { + _setBindingTelemetryWorkerFactory(() => { + const worker = new FakeWorker(); + worker.reply({ + kind: 'snapshot', + snapshot: { + consent: 'granted', + statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + policy: 'allowed', + needsPrompt: false, + }, + }); + return worker; + }); + assert.deepStrictEqual(await queryTelemetryConsentAsync(), { state: 'granted', storedState: 'granted', @@ -67,225 +101,188 @@ describe('telemetry consent', () => { }); }); - it('queries status through the dedicated consent command', async () => { - let args: readonly string[] = []; - _setTelemetryConsentAsyncRunner(async (value) => { - args = value; - return commandOutput(status('undetermined', 'unrestricted', true)); + it('fails closed when the native snapshot is internally inconsistent', async () => { + _setBindingTelemetryWorkerFactory(() => { + const worker = new FakeWorker(); + worker.reply({ + kind: 'snapshot', + snapshot: { + consent: 'granted', + statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + policy: 'blocked', + needsPrompt: false, + }, + }); + return worker; + }); + + const query = await queryTelemetryConsentAsync(); + assert.deepStrictEqual({ + ...query, + error: undefined, + }, { + state: 'undetermined', + storedState: 'undetermined', + effectiveState: 'undetermined', + needsPrompt: false, + policy: 'blocked', + error: undefined, }); - assert.strictEqual((await queryTelemetryConsentAsync()).needsPrompt, true); - assert.deepStrictEqual(args, ['--telemetry-consent', 'status']); - assert.ok(!args.includes('--config-base64')); + assert.match(query.error ?? '', /unrecognised telemetry consent output/); }); it('deduplicates variable fail-closed details by operation and safe result', async () => { _resetTelemetryFailureReporting(); const warnings: string[] = []; const originalWarn = console.warn; + let call = 0; console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' ')); try { - _setTelemetryConsentAsyncRunner(async () => commandOutput('not json')); - assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined'); + _setBindingTelemetryWorkerFactory(() => { + const worker = new FakeWorker(); + const statusJson = call === 0 ? 'not json' : '{"storedState":1}'; + call += 1; + worker.reply({ + kind: 'snapshot', + snapshot: { + consent: 'undetermined', + statusJson, + policy: 'blocked', + needsPrompt: false, + }, + }); + return worker; + }); assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined'); - _setTelemetryConsentAsyncRunner(async () => commandOutput('different invalid output')); assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined'); } finally { console.warn = originalWarn; } assert.strictEqual(warnings.length, 1); - assert.ok(warnings.every((warning) => /fail-closed/.test(warning))); + assert.ok(warnings[0]?.includes('fail-closed')); }); - it('deduplicates variable native status diagnostics without changing the typed result', async () => { - _resetTelemetryFailureReporting(); - const warnings: string[] = []; - const originalWarn = console.warn; - console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' ')); - try { - let call = 0; - _setTelemetryConsentAsyncRunner(async () => { - call += 1; - return commandOutput( - JSON.stringify({ - action: 'status', - result: 'status', - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: false, - policy: 'blocked', - reason: 'store-unreadable', - }), - `mxc: telemetry consent store could not be read (${call})`, - ); - }); - const first = await queryTelemetryConsentAsync(); - const second = await queryTelemetryConsentAsync(); - assert.strictEqual(first.effectiveState, 'undetermined'); - assert.strictEqual(first.policy, 'blocked'); - assert.strictEqual(first.error, undefined); - assert.deepStrictEqual(second, first); - } finally { - console.warn = originalWarn; - } - assert.deepStrictEqual(warnings, [ - 'mxc-sdk: queryTelemetryConsentAsync native diagnostic: ' - + 'mxc: telemetry consent store could not be read (1)', - ]); - }); + it('maps a typed presenter decision onto the native callback result', async () => { + let requestData: TelemetryWorkerData | undefined; + const worker = new FakeWorker(); + _setBindingTelemetryWorkerFactory((data) => { + requestData = data; + return worker; + }); - it('fails closed when native prompt eligibility conflicts with consent state or policy', async () => { - for (const response of [ - status('granted', 'allowed', true), - status('undetermined', 'blocked', true), - ]) { - _setTelemetryConsentAsyncRunner(async () => commandOutput(response)); - const query = await queryTelemetryConsentAsync(); - assert.deepStrictEqual({ - ...query, - error: undefined, - }, { - state: 'undetermined', - storedState: 'undetermined', - effectiveState: 'undetermined', - needsPrompt: false, - policy: 'blocked', - error: undefined, - }); - assert.match(query.error ?? '', /unrecognised telemetry consent output/); - } - }); + const promise = requestTelemetryConsent((value) => { + assert.deepStrictEqual(value, prompt); + return 'yes'; + }, 'en-US'); + await waitFor(() => requestData?.operation === 'request'); + const decision = new Int32Array((requestData as Extract).decisionShared); - it('fails status queries closed for mismatched actions and invalid results', async () => { - _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({ - action: 'status', - result: 'withdrawn', - storedState: 'granted', - effectiveState: 'granted', - needsPrompt: false, - policy: 'allowed', - reason: null, - }))); - const asyncQuery = await queryTelemetryConsentAsync(); - assert.strictEqual(asyncQuery.effectiveState, 'undetermined'); - assert.strictEqual(asyncQuery.policy, 'blocked'); - assert.strictEqual(asyncQuery.needsPrompt, false); - }); + worker.reply({ kind: 'present', promptJson }); + await waitFor(() => Atomics.load(decision, 0) === 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_DECISION_YES); + worker.reply({ + kind: 'payload', + payload: '{"result":"granted","storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + }); - it('fails status queries closed when the native response omits reason', async () => { - _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({ - action: 'status', - result: 'status', + assert.deepStrictEqual(await promise, { + action: 'request', + result: 'granted', storedState: 'granted', effectiveState: 'granted', needsPrompt: false, policy: 'allowed', - }))); - const query = await queryTelemetryConsentAsync(); - assert.strictEqual(query.state, 'undetermined'); - assert.strictEqual(query.storedState, 'undetermined'); - assert.strictEqual(query.effectiveState, 'undetermined'); - assert.strictEqual(query.policy, 'blocked'); - assert.strictEqual(query.needsPrompt, false); + }); + assert.strictEqual((requestData as Extract).locale, 'en-US'); }); - it('binds a synchronous presenter decision to the canonical prompt', async () => { - let observedLocale: string | undefined; - _setTelemetryConsentProtocolRunner(async (locale, presenter) => { - observedLocale = locale; - const decision = await presenter(prompt); - assert.strictEqual(decision, 'yes'); - return { - action: 'request', - result: 'granted', - storedState: 'granted', - effectiveState: 'granted', - needsPrompt: false, - policy: 'unrestricted', - }; + it('rejects invalid presenter decisions and returns the original failure', async () => { + let requestData: TelemetryWorkerData | undefined; + const worker = new FakeWorker(); + _setBindingTelemetryWorkerFactory((data) => { + requestData = data; + return worker; }); - const outcome = await requestTelemetryConsent((value) => { - assert.deepStrictEqual(value, prompt); - return 'yes'; - }, 'en-US'); - assert.strictEqual(observedLocale, 'en-US'); - assert.strictEqual(outcome.result, 'granted'); + const promise = requestTelemetryConsent(() => 'maybe' as unknown as 'yes'); + await waitFor(() => requestData?.operation === 'request'); + const decision = new Int32Array((requestData as Extract).decisionShared); + + worker.reply({ kind: 'present', promptJson }); + await waitFor(() => Atomics.load(decision, 0) === 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); + worker.reply({ + kind: 'error', + error: { code: 'backend_error', message: 'requesting telemetry consent failed' }, + }); + + await assert.rejects(promise, /invalid decision/); }); - it('supports an asynchronous presenter and propagates presenter failure', async () => { - _setTelemetryConsentProtocolRunner(async (_locale, presenter) => { - await presenter(prompt); - throw new Error('should not continue'); + it('rejects locales containing embedded NUL characters', async () => { + let called = false; + _setBindingTelemetryWorkerFactory(() => { + called = true; + return new FakeWorker(); }); await assert.rejects( - requestTelemetryConsent(async () => { - await Promise.resolve(); - throw new Error('UI unavailable'); - }), - /UI unavailable/, + requestTelemetryConsent(() => 'yes', 'en-US\0dev'), + /embedded NUL/, ); + assert.strictEqual(called, false); }); - it('queries and withdraws through the non-blocking runner', async () => { - const actions: string[] = []; - _setTelemetryConsentAsyncRunner(async (args) => { - assert.deepStrictEqual(args.slice(0, 1), ['--telemetry-consent']); - const action = args[1]!; - actions.push(action); - return commandOutput(action === 'status' - ? status('granted', 'allowed') - : JSON.stringify({ - action: 'withdraw', - result: 'withdrawn', - storedState: 'denied', - effectiveState: 'denied', - needsPrompt: false, - policy: 'unrestricted', - reason: null, - })); + it('parses and withdraws through the worker-backed binding', async () => { + let call = 0; + _setBindingTelemetryWorkerFactory(() => { + const worker = new FakeWorker(); + if (call === 0) { + worker.reply({ + kind: 'snapshot', + snapshot: { + consent: 'granted', + statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + policy: 'allowed', + needsPrompt: false, + }, + }); + } else { + worker.reply({ + kind: 'payload', + payload: '{"result":"withdrawn","storedState":"denied","effectiveState":"denied","reason":null,"policy":"unrestricted"}', + }); + } + call += 1; + return worker; }); assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'granted'); - assert.strictEqual((await withdrawTelemetryConsentAsync()).result, 'withdrawn'); - assert.deepStrictEqual(actions, ['status', 'withdraw']); - }); - - it('rejects withdrawal responses with mismatched actions or invalid results', async () => { - _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({ - action: 'withdraw', - result: 'status', - storedState: 'denied', - effectiveState: 'denied', - needsPrompt: false, - policy: 'blocked', - reason: null, - }))); - await assert.rejects( - withdrawTelemetryConsentAsync(), - /unrecognised telemetry consent output/, - ); - }); - - it('rejects withdrawal responses that omit reason', async () => { - _setTelemetryConsentAsyncRunner(async () => commandOutput(JSON.stringify({ + assert.deepStrictEqual(await withdrawTelemetryConsentAsync(), { action: 'withdraw', result: 'withdrawn', storedState: 'denied', effectiveState: 'denied', needsPrompt: false, policy: 'unrestricted', - }))); - await assert.rejects( - withdrawTelemetryConsentAsync(), - /unrecognised telemetry consent output/, - ); + }); + }); + + it('rejects withdrawal responses with invalid results', async () => { + _setBindingTelemetryWorkerFactory(() => { + const worker = new FakeWorker(); + worker.reply({ + kind: 'payload', + payload: '{"result":"status","storedState":"denied","effectiveState":"denied","reason":null,"policy":"blocked"}', + }); + return worker; + }); + await assert.rejects(withdrawTelemetryConsentAsync(), /unrecognised telemetry consent output/); }); }); describe('telemetry consent is Windows-only', () => { afterEach(() => { - _setTelemetryConsentAsyncRunner(null); - _setTelemetryConsentProtocolRunner(null); + _setBindingTelemetryWorkerFactory(); _setTelemetryPlatform(null); }); @@ -293,13 +290,9 @@ describe('telemetry consent is Windows-only', () => { it(`does not query or present consent on ${platform}`, async () => { _setTelemetryPlatform(platform); let called = false; - _setTelemetryConsentAsyncRunner(async () => { - called = true; - throw new Error('must not run'); - }); - _setTelemetryConsentProtocolRunner(async () => { + _setBindingTelemetryWorkerFactory(() => { called = true; - throw new Error('must not run'); + return new FakeWorker(); }); const request = await requestTelemetryConsent(() => { From 514c996fddfb69d8e1c7418145830cf3392d0eea Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:32:18 -0700 Subject: [PATCH 08/11] Simplify Node telemetry native bridge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- sdk/node/README.md | 2 +- sdk/node/package.json | 2 +- .../telemetry-request-worker-entry.ts | 50 +++ ...-worker.ts => telemetry-request-worker.ts} | 89 +---- .../src/bindings/telemetry-worker-entry.ts | 63 ---- sdk/node/src/bindings/telemetry.ts | 234 +++++++------ sdk/node/src/telemetry.ts | 30 +- ...st.ts => telemetry-request-worker.test.ts} | 315 ++++++++---------- sdk/node/tests/unit/telemetry.test.ts | 146 +++----- 9 files changed, 382 insertions(+), 549 deletions(-) create mode 100644 sdk/node/src/bindings/telemetry-request-worker-entry.ts rename sdk/node/src/bindings/{telemetry-worker.ts => telemetry-request-worker.ts} (56%) delete mode 100644 sdk/node/src/bindings/telemetry-worker-entry.ts rename sdk/node/tests/unit/{default-consent-protocol-runner.test.ts => telemetry-request-worker.test.ts} (67%) diff --git a/sdk/node/README.md b/sdk/node/README.md index e86cbfc1b..5a8324ce3 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -157,7 +157,7 @@ The default `processcontainer`, `bubblewrap`, `lxc`, `seatbelt`, `wslc`, and `is > **Hyperlight** is an opt-in build flavor (Linux x64 and Windows x64) gated by the `--with-hyperlight` cargo feature. Default shipped binaries do not include it; build from source with `build.bat --with-hyperlight` (Windows) or the equivalent cargo invocation on Linux. -`getPlatformSupport()` reports backend availability. Its `uiCapabilities` field is reserved for a future native host-services expansion and is currently omitted on all platforms. On Linux, `unavailableReasons` provides a diagnostic for each unavailable LXC or Bubblewrap backend even when the other backend keeps the platform supported. +`getPlatformSupport()` reports backend availability and, when the native probe can determine it, `uiCapabilities`: a platform-neutral view of which UI restrictions the host can enforce. This is currently populated only by the Windows native probe, where it is derived from `JOB_OBJECT_UILIMIT_*` support; Linux and macOS omit the field until their probes expose equivalent data. On Linux, `unavailableReasons` provides a diagnostic for each unavailable LXC or Bubblewrap backend even when the other backend keeps the platform supported. On Linux, when Bubblewrap is available, `getPlatformSupport()` also reports `bubblewrapNetwork`: whether this host can enforce **proxy-only egress** (schema `0.8.0-alpha`+ proxy mode, which runs the sandbox in a private network namespace and default-drops everything except the proxy). That mode has no fallback — a policy the host cannot satisfy fails rather than silently degrading — so check it before spawning: diff --git a/sdk/node/package.json b/sdk/node/package.json index 05abaeac3..3fd6f79cf 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -23,7 +23,7 @@ "watch": "tsc --watch", "clean": "rimraf dist", "test": "npm run test:unit", - "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/state-aware-binding.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/native-library.test.js dist-tests/tests/unit/binding-request.test.js dist-tests/tests/unit/binding-run.test.js dist-tests/tests/unit/streaming-binding.test.js dist-tests/tests/unit/inprocess-run.test.js dist-tests/tests/unit/sandbox-process.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/default-consent-protocol-runner.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", + "test:unit": "npm run build:test-unit && node --test dist-tests/tests/unit/sandbox.test.js dist-tests/tests/unit/policy.test.js dist-tests/tests/unit/logger.test.js dist-tests/tests/unit/errors.test.js dist-tests/tests/unit/state-aware-types.test.js dist-tests/tests/unit/state-aware.test.js dist-tests/tests/unit/state-aware-binding.test.js dist-tests/tests/unit/platform.test.js dist-tests/tests/unit/native-library.test.js dist-tests/tests/unit/binding-request.test.js dist-tests/tests/unit/binding-run.test.js dist-tests/tests/unit/streaming-binding.test.js dist-tests/tests/unit/inprocess-run.test.js dist-tests/tests/unit/sandbox-process.test.js dist-tests/tests/unit/telemetry.test.js dist-tests/tests/unit/telemetry-request-worker.test.js dist-tests/tests/unit/wire-conformance.test.js dist-tests/tests/unit/wire-conformance-state-aware.test.js", "test:integration": "cd tests/integration && npm install && npm run build && npm test", "prepublishOnly": "npm run build", "typecheck:integration": "cd tests/integration && npx tsc --noEmit -p tsconfig.json", diff --git a/sdk/node/src/bindings/telemetry-request-worker-entry.ts b/sdk/node/src/bindings/telemetry-request-worker-entry.ts new file mode 100644 index 000000000..9874bf41f --- /dev/null +++ b/sdk/node/src/bindings/telemetry-request-worker-entry.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Worker-thread entry point for the blocking native consent request. + +import { parentPort, workerData } from 'node:worker_threads'; +import { MxcError } from '../errors.js'; +import { + requestTelemetryConsentJson, +} from './telemetry.js'; +import type { + TelemetryRequestWorkerData, + TelemetryRequestWorkerMessage, +} from './telemetry-request-worker.js'; + +function serializeError(error: unknown) { + if (error instanceof MxcError) { + return { + code: error.code, + message: error.message, + operation: error.operation, + nativeCode: error.nativeCode, + remediation: error.remediation, + details: error.details, + }; + } + return { + code: 'backend_error' as const, + message: error instanceof Error ? error.message : String(error), + }; +} + +const data = workerData as TelemetryRequestWorkerData; +let message: TelemetryRequestWorkerMessage; +try { + const decision = new Int32Array(data.decisionShared); + message = { + kind: 'payload', + payload: requestTelemetryConsentJson(data.locale, (promptJson) => { + Atomics.store(decision, 0, 0); + Atomics.store(decision, 1, 0); + parentPort!.postMessage({ kind: 'present', promptJson } satisfies TelemetryRequestWorkerMessage); + Atomics.wait(decision, 0, 0); + return Atomics.load(decision, 1); + }), + }; +} catch (error) { + message = { kind: 'error', error: serializeError(error) }; +} +parentPort!.postMessage(message); diff --git a/sdk/node/src/bindings/telemetry-worker.ts b/sdk/node/src/bindings/telemetry-request-worker.ts similarity index 56% rename from sdk/node/src/bindings/telemetry-worker.ts rename to sdk/node/src/bindings/telemetry-request-worker.ts index f1e36dd0e..ef276e06c 100644 --- a/sdk/node/src/bindings/telemetry-worker.ts +++ b/sdk/node/src/bindings/telemetry-request-worker.ts @@ -1,51 +1,32 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Main-thread bridge for telemetry calls that block in the native runtime. -// Consent presentation remains on this thread while persistence runs in a worker. +// Main-thread bridge for the blocking native consent presenter callback. import { Worker } from 'node:worker_threads'; import { MxcError, type MxcErrorFields } from '../errors.js'; -import { - TELEMETRY_CONSENT_PRESENTER_ERROR, - type TelemetryConsentSnapshot, -} from './telemetry.js'; - -export interface TelemetryQueryWorkerData { - operation: 'query'; -} - -export interface TelemetryWithdrawWorkerData { - operation: 'withdraw'; -} +import { TELEMETRY_CONSENT_PRESENTER_ERROR } from './telemetry.js'; export interface TelemetryRequestWorkerData { - operation: 'request'; locale?: string; decisionShared: SharedArrayBuffer; } -export type TelemetryWorkerData = - | TelemetryQueryWorkerData - | TelemetryWithdrawWorkerData - | TelemetryRequestWorkerData; - -export type TelemetryWorkerMessage = - | { kind: 'snapshot'; snapshot: TelemetryConsentSnapshot } +export type TelemetryRequestWorkerMessage = | { kind: 'payload'; payload: string } | { kind: 'present'; promptJson: string } | { kind: 'error'; error: MxcErrorFields }; export interface BindingTelemetryWorkerLike { - on(event: 'message', listener: (message: TelemetryWorkerMessage) => void): this; + on(event: 'message', listener: (message: TelemetryRequestWorkerMessage) => void): this; on(event: 'error', listener: (error: Error) => void): this; on(event: 'exit', listener: (code: number) => void): this; } -type WorkerFactory = (data: TelemetryWorkerData) => BindingTelemetryWorkerLike; +type WorkerFactory = (data: TelemetryRequestWorkerData) => BindingTelemetryWorkerLike; const defaultWorkerFactory: WorkerFactory = (data) => new Worker( - new URL('./telemetry-worker-entry.js', import.meta.url), + new URL('./telemetry-request-worker-entry.js', import.meta.url), { workerData: data, execArgv: [] }, ); @@ -59,63 +40,6 @@ function serializeUnknownError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -function runTelemetryWorker( - data: TelemetryWorkerData, - handleMessage: ( - message: TelemetryWorkerMessage, - finish: (action: () => void) => void, - resolve: (value: T) => void, - reject: (reason?: unknown) => void, - ) => void, -): Promise { - return new Promise((resolve, reject) => { - const worker = workerFactory(data); - let settled = false; - const finish = (action: () => void) => { - if (settled) { - return; - } - settled = true; - action(); - }; - - worker.on('message', (message) => handleMessage(message, finish, resolve, reject)); - worker.on('error', (error) => finish(() => reject(error))); - worker.on('exit', (code) => finish(() => reject(new MxcError({ - code: 'backend_error', - message: `telemetry worker exited before returning a result (code ${code})`, - })))); - }); -} - -export function runTelemetryConsentQueryAsync(): Promise { - return runTelemetryWorker({ operation: 'query' }, (message, finish, resolve, reject) => { - if (message.kind === 'snapshot') { - finish(() => resolve(message.snapshot)); - return; - } - if (message.kind === 'error') { - finish(() => reject(new MxcError(message.error))); - return; - } - finish(() => reject(new Error('telemetry query worker returned an unexpected message'))); - }); -} - -export function runTelemetryConsentWithdrawAsync(): Promise { - return runTelemetryWorker({ operation: 'withdraw' }, (message, finish, resolve, reject) => { - if (message.kind === 'payload') { - finish(() => resolve(message.payload)); - return; - } - if (message.kind === 'error') { - finish(() => reject(new MxcError(message.error))); - return; - } - finish(() => reject(new Error('telemetry withdrawal worker returned an unexpected message'))); - }); -} - export function runTelemetryConsentRequestAsync( locale: string | undefined, presenter: (promptJson: string, signal: AbortSignal) => number | Promise, @@ -123,7 +47,6 @@ export function runTelemetryConsentRequestAsync( const decision = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 2)); return new Promise((resolve, reject) => { const worker = workerFactory({ - operation: 'request', locale, decisionShared: decision.buffer as SharedArrayBuffer, }); diff --git a/sdk/node/src/bindings/telemetry-worker-entry.ts b/sdk/node/src/bindings/telemetry-worker-entry.ts deleted file mode 100644 index ebf60aa9f..000000000 --- a/sdk/node/src/bindings/telemetry-worker-entry.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Worker-thread entry point for blocking telemetry persistence operations. - -import { parentPort, workerData } from 'node:worker_threads'; -import { MxcError } from '../errors.js'; -import { - readTelemetryConsentSnapshot, - requestTelemetryConsentJson, - withdrawTelemetryConsentJson, -} from './telemetry.js'; -import type { - TelemetryWorkerData, - TelemetryWorkerMessage, -} from './telemetry-worker.js'; - -function serializeError(error: unknown) { - if (error instanceof MxcError) { - return { - code: error.code, - message: error.message, - operation: error.operation, - nativeCode: error.nativeCode, - remediation: error.remediation, - details: error.details, - }; - } - return { - code: 'backend_error' as const, - message: error instanceof Error ? error.message : String(error), - }; -} - -const data = workerData as TelemetryWorkerData; -let message: TelemetryWorkerMessage; -try { - switch (data.operation) { - case 'query': - message = { kind: 'snapshot', snapshot: readTelemetryConsentSnapshot() }; - break; - case 'withdraw': - message = { kind: 'payload', payload: withdrawTelemetryConsentJson() }; - break; - case 'request': { - const decision = new Int32Array(data.decisionShared); - message = { - kind: 'payload', - payload: requestTelemetryConsentJson(data.locale, (promptJson) => { - Atomics.store(decision, 0, 0); - Atomics.store(decision, 1, 0); - parentPort!.postMessage({ kind: 'present', promptJson } satisfies TelemetryWorkerMessage); - Atomics.wait(decision, 0, 0); - return Atomics.load(decision, 1); - }), - }; - break; - } - } -} catch (error) { - message = { kind: 'error', error: serializeError(error) }; -} -parentPort!.postMessage(message); diff --git a/sdk/node/src/bindings/telemetry.ts b/sdk/node/src/bindings/telemetry.ts index 9f36bf601..b917f7757 100644 --- a/sdk/node/src/bindings/telemetry.ts +++ b/sdk/node/src/bindings/telemetry.ts @@ -1,61 +1,89 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Synchronous native bindings for telemetry consent and administrative policy. +// Native bindings for telemetry consent and administrative policy. import koffi, { type KoffiFunc } from 'koffi'; import { loadMxcFfi } from '../native-library.js'; import { decodeString, nativeStatusError } from './native-error.js'; +import { bindNativeFunction } from './native-function.js'; export const TELEMETRY_CONSENT_DECISION_NO = 0; export const TELEMETRY_CONSENT_DECISION_YES = 1; export const TELEMETRY_CONSENT_DECISION_DISMISSED = 2; export const TELEMETRY_CONSENT_PRESENTER_ERROR = -1; -export interface TelemetryConsentSnapshot { - consent: string; - statusJson: string; - policy: string; - needsPrompt: boolean; -} - const TelemetryConsentPresenter = koffi.proto( 'MxcNodeTelemetryConsentPresenter', 'int32_t', ['const char *', 'void *'], ); -type StringOutFunction = KoffiFunc<(out: unknown[]) => number>; -type BoolOutFunction = KoffiFunc<(out: number[]) => number>; -type StringFreeFunction = KoffiFunc<(value: unknown) => void>; -type RequestConsentFunction = KoffiFunc<( +type StringOutSignature = (out: unknown[]) => number; +type StringFreeSignature = (value: unknown) => void; +type RequestConsentSignature = ( locale: string | null, presenter: ((promptJson: string, context: unknown) => number) | null, context: unknown | null, out: unknown[], -) => number>; +) => number; +type StringOutFunction = KoffiFunc; +type StringFreeFunction = KoffiFunc; +type RequestConsentFunction = KoffiFunc; interface TelemetryApi { - getConsent: StringOutFunction; getConsentStatus: StringOutFunction; - getPolicy: StringOutFunction; - needsConsentPrompt: BoolOutFunction; withdrawConsent: StringOutFunction; requestConsent: RequestConsentFunction; stringFree: StringFreeFunction; } +export interface TelemetryAsyncImplementation { + readConsentStatusJson(): Promise; + withdrawConsentJson(): Promise; +} + +function bindTelemetryApi(native: ReturnType): TelemetryApi { + const stringOutParameters = [koffi.out(koffi.pointer('char', 2))]; + return { + getConsentStatus: bindNativeFunction(native.handle, { + symbol: 'mxc_telemetry_get_consent_status', + result: 'int32_t', + parameters: stringOutParameters, + }), + withdrawConsent: bindNativeFunction(native.handle, { + symbol: 'mxc_telemetry_withdraw_consent', + result: 'int32_t', + parameters: stringOutParameters, + }), + requestConsent: bindNativeFunction(native.handle, { + symbol: 'mxc_telemetry_request_consent', + result: 'int32_t', + parameters: [ + 'const char *', + koffi.pointer(TelemetryConsentPresenter), + 'void *', + koffi.out(koffi.pointer('char', 2)), + ], + }), + stringFree: bindNativeFunction(native.handle, { + symbol: 'mxc_string_free', + result: 'void', + parameters: ['char *'], + }), + }; +} + function isNonNullPointer(value: unknown): boolean { return value !== null && value !== undefined && value !== 0 && value !== 0n; } -function readRequiredString( - invoke: (out: unknown[]) => number, +function decodeRequiredString( + status: number, + out: unknown[], stringFree: StringFreeFunction, message: string, ): string { - const out = [null] as unknown[]; - const status = invoke(out); if (status !== 0) { throw nativeStatusError(status, {}, message); } @@ -75,108 +103,100 @@ function readRequiredString( } } -function readBoolean(invoke: BoolOutFunction, message: string): boolean { - const out = [0]; - const status = invoke(out); - if (status !== 0) { - throw nativeStatusError(status, {}, message); - } - return out[0] !== 0; +function readRequiredString( + invoke: (out: unknown[]) => number, + stringFree: StringFreeFunction, + message: string, +): string { + const out = [null] as unknown[]; + return decodeRequiredString(invoke(out), out, stringFree, message); } -function withTelemetryApi(action: (api: TelemetryApi) => T): T { +function readRequiredStringAsync( + invoke: StringOutFunction, + stringFree: StringFreeFunction, + message: string, +): Promise { + const out = [null] as unknown[]; + return new Promise((resolve, reject) => { + invoke.async(out, (error, status) => { + if (error !== null) { + reject(error); + return; + } + try { + resolve(decodeRequiredString(status, out, stringFree, message)); + } catch (decodeError) { + reject(decodeError); + } + }); + }); +} + +async function readTelemetryStringAsync( + select: (api: TelemetryApi) => StringOutFunction, + message: string, +): Promise { const native = loadMxcFfi(); try { - const handle = native.handle; - const api: TelemetryApi = { - getConsent: handle.func( - 'mxc_telemetry_get_consent', - 'int32_t', - [koffi.out(koffi.pointer('char', 2))], - ) as StringOutFunction, - getConsentStatus: handle.func( - 'mxc_telemetry_get_consent_status', - 'int32_t', - [koffi.out(koffi.pointer('char', 2))], - ) as StringOutFunction, - getPolicy: handle.func( - 'mxc_telemetry_get_policy', - 'int32_t', - [koffi.out(koffi.pointer('char', 2))], - ) as StringOutFunction, - needsConsentPrompt: handle.func( - 'mxc_telemetry_needs_consent_prompt', - 'int32_t', - [koffi.out(koffi.pointer('int32_t'))], - ) as BoolOutFunction, - withdrawConsent: handle.func( - 'mxc_telemetry_withdraw_consent', - 'int32_t', - [koffi.out(koffi.pointer('char', 2))], - ) as StringOutFunction, - requestConsent: handle.func( - 'mxc_telemetry_request_consent', - 'int32_t', - ['const char *', koffi.pointer(TelemetryConsentPresenter), 'void *', koffi.out(koffi.pointer('char', 2))], - ) as RequestConsentFunction, - stringFree: handle.func('mxc_string_free', 'void', ['char *']) as StringFreeFunction, - }; - return action(api); + const api = bindTelemetryApi(native); + return await readRequiredStringAsync(select(api), api.stringFree, message); } finally { native.handle.unload(); } } -export function readTelemetryConsentSnapshot(): TelemetryConsentSnapshot { - return withTelemetryApi((api) => ({ - consent: readRequiredString( - (out) => api.getConsent(out), - api.stringFree, - 'reading telemetry consent failed', - ), - statusJson: readRequiredString( - (out) => api.getConsentStatus(out), - api.stringFree, - 'reading telemetry consent status failed', - ), - policy: readRequiredString( - (out) => api.getPolicy(out), - api.stringFree, - 'reading telemetry policy failed', - ), - needsPrompt: readBoolean( - api.needsConsentPrompt, - 'checking telemetry consent prompt eligibility failed', - ), - })); +const defaultAsyncImplementation: TelemetryAsyncImplementation = { + readConsentStatusJson: () => readTelemetryStringAsync( + (api) => api.getConsentStatus, + 'reading telemetry consent status failed', + ), + withdrawConsentJson: () => readTelemetryStringAsync( + (api) => api.withdrawConsent, + 'withdrawing telemetry consent failed', + ), +}; +let asyncImplementation = defaultAsyncImplementation; + +/** @internal Replaces async native calls for one process's unit tests. */ +export function _setBindingTelemetryAsyncImplementation( + implementation?: TelemetryAsyncImplementation, +): void { + asyncImplementation = implementation ?? defaultAsyncImplementation; } -export function withdrawTelemetryConsentJson(): string { - return withTelemetryApi((api) => readRequiredString( - (out) => api.withdrawConsent(out), - api.stringFree, - 'withdrawing telemetry consent failed', - )); +export function readTelemetryConsentStatusJsonAsync(): Promise { + return asyncImplementation.readConsentStatusJson(); +} + +export function withdrawTelemetryConsentJsonAsync(): Promise { + return asyncImplementation.withdrawConsentJson(); } export function requestTelemetryConsentJson( locale: string | undefined, presenter: (promptJson: string) => number, ): string { - return withTelemetryApi((api) => readRequiredString( - (out) => api.requestConsent( - locale ?? null, - (promptJson) => { - try { - return presenter(promptJson); - } catch { - return TELEMETRY_CONSENT_PRESENTER_ERROR; - } - }, - null, - out, - ), - api.stringFree, - 'requesting telemetry consent failed', - )); + const native = loadMxcFfi(); + try { + const api = bindTelemetryApi(native); + return readRequiredString( + (out) => api.requestConsent( + locale ?? null, + (promptJson) => { + try { + return presenter(promptJson); + } catch { + return TELEMETRY_CONSENT_PRESENTER_ERROR; + } + }, + null, + out, + ), + api.stringFree, + 'requesting telemetry consent failed', + ); + } finally { + native.handle.unload(); + } } diff --git a/sdk/node/src/telemetry.ts b/sdk/node/src/telemetry.ts index 9c1df8e80..d7460411a 100644 --- a/sdk/node/src/telemetry.ts +++ b/sdk/node/src/telemetry.ts @@ -2,16 +2,13 @@ // Licensed under the MIT License. import { + readTelemetryConsentStatusJsonAsync, TELEMETRY_CONSENT_DECISION_DISMISSED, TELEMETRY_CONSENT_DECISION_NO, TELEMETRY_CONSENT_DECISION_YES, - type TelemetryConsentSnapshot, + withdrawTelemetryConsentJsonAsync, } from './bindings/telemetry.js'; -import { - runTelemetryConsentQueryAsync, - runTelemetryConsentRequestAsync, - runTelemetryConsentWithdrawAsync, -} from './bindings/telemetry-worker.js'; +import { runTelemetryConsentRequestAsync } from './bindings/telemetry-request-worker.js'; const TELEMETRY_CONSENT_STATES = ['granted', 'denied', 'undetermined', 'not-applicable'] as const; const TELEMETRY_POLICY_STATES = ['unrestricted', 'allowed', 'blocked', 'not-applicable'] as const; @@ -252,20 +249,6 @@ function decisionCode(decision: TelemetryConsentDecision): number { } } -function validateSnapshot(snapshot: TelemetryConsentSnapshot): TelemetryConsentStatusPayload { - const status = parseStatusPayload(snapshot.statusJson); - if ( - !isConsentState(snapshot.consent) - || !isPolicyState(snapshot.policy) - || snapshot.consent !== status.effectiveState - || snapshot.policy !== status.policy - || snapshot.needsPrompt !== shouldPrompt(status.effectiveState, status.policy) - ) { - throw invalidTelemetryOutput(snapshot.statusJson); - } - return status; -} - function tryRegisterFailureCategory(category: string): boolean { if ( reportedFailureCategories.has(category) @@ -352,13 +335,12 @@ export async function queryTelemetryConsentAsync(): Promise this.emit('message', message)); - } - - fail(error: Error): void { - queueMicrotask(() => this.emit('error', error)); - } - - exit(code: number): void { - queueMicrotask(() => this.emit('exit', code)); - } -} - -function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { - const started = Date.now(); - return new Promise((resolve, reject) => { - const timer = setInterval(() => { - if (condition()) { - clearInterval(timer); - resolve(); - return; - } - if (Date.now() - started > timeoutMs) { - clearInterval(timer); - reject(new Error(`condition did not become true within ${timeoutMs}ms`)); - } - }, 5); - }); -} - -describe('telemetry worker bindings', () => { - let worker: FakeWorker; - let workerData: TelemetryWorkerData | undefined; - - beforeEach(() => { - worker = new FakeWorker(); - workerData = undefined; - _setBindingTelemetryWorkerFactory((data) => { - workerData = data; - return worker; - }); - }); - - afterEach(() => { - _setBindingTelemetryWorkerFactory(); - }); - - it('returns read-only consent snapshots from the worker', async () => { - const promise = runTelemetryConsentQueryAsync(); - worker.reply({ - kind: 'snapshot', - snapshot: { - consent: 'granted', - statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', - policy: 'allowed', - needsPrompt: false, - }, - }); - assert.deepStrictEqual(await promise, { - consent: 'granted', - statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', - policy: 'allowed', - needsPrompt: false, - }); - }); - - it('returns withdrawal payloads from the worker', async () => { - const promise = runTelemetryConsentWithdrawAsync(); - worker.reply({ - kind: 'payload', - payload: '{"result":"withdrawn","storedState":"denied","effectiveState":"denied","reason":null,"policy":"unrestricted"}', - }); - assert.strictEqual( - await promise, - '{"result":"withdrawn","storedState":"denied","effectiveState":"denied","reason":null,"policy":"unrestricted"}', - ); - }); - - it('relays an async presenter decision through the shared callback buffer', async () => { - const promise = runTelemetryConsentRequestAsync('en-US', async (promptJson, signal) => { - assert.match(promptJson, /"locale":"en-US"/); - assert.strictEqual(signal.aborted, false); - await Promise.resolve(); - return TELEMETRY_CONSENT_DECISION_YES; - }); - await waitFor(() => workerData?.operation === 'request'); - const decision = new Int32Array((workerData as Extract).decisionShared); - - worker.reply({ - kind: 'present', - promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', - }); - - await waitFor(() => Atomics.load(decision, 0) === 1); - assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_DECISION_YES); - - worker.reply({ - kind: 'payload', - payload: '{"result":"granted","storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', - }); - - assert.strictEqual( - await promise, - '{"result":"granted","storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', - ); - }); - - it('preserves the original presenter failure instead of the native fallback error', async () => { - const promise = runTelemetryConsentRequestAsync(undefined, () => { - throw new Error('UI unavailable'); - }); - await waitFor(() => workerData?.operation === 'request'); - const decision = new Int32Array((workerData as Extract).decisionShared); - - worker.reply({ - kind: 'present', - promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', - }); - - await waitFor(() => Atomics.load(decision, 0) === 1); - assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); - - worker.reply({ - kind: 'error', - error: { code: 'backend_error', message: 'requesting telemetry consent failed' }, - }); - - await assert.rejects(promise, /UI unavailable/); - }); - - it('aborts the presenter signal and wakes the native callback on worker exit', async () => { - let observedSignal: AbortSignal | undefined; - const promise = runTelemetryConsentRequestAsync(undefined, async (_promptJson, signal) => { - observedSignal = signal; - await new Promise(() => {}); - return TELEMETRY_CONSENT_DECISION_YES; - }); - await waitFor(() => workerData?.operation === 'request'); - const decision = new Int32Array((workerData as Extract).decisionShared); - - worker.reply({ - kind: 'present', - promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', - }); - await waitFor(() => observedSignal !== undefined); - - worker.exit(9); - - await assert.rejects(promise, /worker exited before returning a result/); - assert.strictEqual(observedSignal?.aborted, true); - assert.strictEqual(Atomics.load(decision, 0), 1); - assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); - }); -}); +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert'; +import { EventEmitter } from 'node:events'; + +import { + _setBindingTelemetryWorkerFactory, + runTelemetryConsentRequestAsync, + type BindingTelemetryWorkerLike, + type TelemetryRequestWorkerData, + type TelemetryRequestWorkerMessage, +} from '../../src/bindings/telemetry-request-worker.js'; +import { + TELEMETRY_CONSENT_DECISION_YES, + TELEMETRY_CONSENT_PRESENTER_ERROR, +} from '../../src/bindings/telemetry.js'; + +class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { + reply(message: TelemetryRequestWorkerMessage): void { + queueMicrotask(() => this.emit('message', message)); + } + + exit(code: number): void { + queueMicrotask(() => this.emit('exit', code)); + } +} + +function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { + const started = Date.now(); + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + if (condition()) { + clearInterval(timer); + resolve(); + return; + } + if (Date.now() - started > timeoutMs) { + clearInterval(timer); + reject(new Error(`condition did not become true within ${timeoutMs}ms`)); + } + }, 5); + }); +} + +describe('telemetry consent request worker', () => { + let worker: FakeWorker; + let workerData: TelemetryRequestWorkerData | undefined; + + beforeEach(() => { + worker = new FakeWorker(); + workerData = undefined; + _setBindingTelemetryWorkerFactory((data) => { + workerData = data; + return worker; + }); + }); + + afterEach(() => { + _setBindingTelemetryWorkerFactory(); + }); + + it('relays an async presenter decision through the shared callback buffer', async () => { + const promise = runTelemetryConsentRequestAsync('en-US', async (promptJson, signal) => { + assert.match(promptJson, /"locale":"en-US"/); + assert.strictEqual(signal.aborted, false); + await Promise.resolve(); + return TELEMETRY_CONSENT_DECISION_YES; + }); + await waitFor(() => workerData !== undefined); + const decision = new Int32Array(workerData!.decisionShared); + + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', + }); + + await waitFor(() => Atomics.load(decision, 0) === 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_DECISION_YES); + + worker.reply({ + kind: 'payload', + payload: '{"result":"granted","storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + }); + + assert.strictEqual( + await promise, + '{"result":"granted","storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', + ); + }); + + it('preserves the original presenter failure instead of the native fallback error', async () => { + const promise = runTelemetryConsentRequestAsync(undefined, () => { + throw new Error('UI unavailable'); + }); + await waitFor(() => workerData !== undefined); + const decision = new Int32Array(workerData!.decisionShared); + + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', + }); + + await waitFor(() => Atomics.load(decision, 0) === 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); + + worker.reply({ + kind: 'error', + error: { code: 'backend_error', message: 'requesting telemetry consent failed' }, + }); + + await assert.rejects(promise, /UI unavailable/); + }); + + it('aborts the presenter signal and wakes the native callback on worker exit', async () => { + let observedSignal: AbortSignal | undefined; + const promise = runTelemetryConsentRequestAsync(undefined, async (_promptJson, signal) => { + observedSignal = signal; + await new Promise(() => {}); + return TELEMETRY_CONSENT_DECISION_YES; + }); + await waitFor(() => workerData !== undefined); + const decision = new Int32Array(workerData!.decisionShared); + + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', + }); + await waitFor(() => observedSignal !== undefined); + + worker.exit(9); + + await assert.rejects(promise, /worker exited before returning a result/); + assert.strictEqual(observedSignal?.aborted, true); + assert.strictEqual(Atomics.load(decision, 0), 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); + }); +}); diff --git a/sdk/node/tests/unit/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts index 167de01bd..f52b0d05d 100644 --- a/sdk/node/tests/unit/telemetry.test.ts +++ b/sdk/node/tests/unit/telemetry.test.ts @@ -14,28 +14,22 @@ import { type TelemetryConsentPrompt, } from '../../src/telemetry.js'; import { - _setBindingTelemetryWorkerFactory, - type BindingTelemetryWorkerLike, - type TelemetryWorkerData, - type TelemetryWorkerMessage, -} from '../../src/bindings/telemetry-worker.js'; -import { + _setBindingTelemetryAsyncImplementation, TELEMETRY_CONSENT_DECISION_YES, TELEMETRY_CONSENT_PRESENTER_ERROR, + type TelemetryAsyncImplementation, } from '../../src/bindings/telemetry.js'; +import { + _setBindingTelemetryWorkerFactory, + type BindingTelemetryWorkerLike, + type TelemetryRequestWorkerData, + type TelemetryRequestWorkerMessage, +} from '../../src/bindings/telemetry-request-worker.js'; class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { - reply(message: TelemetryWorkerMessage): void { + reply(message: TelemetryRequestWorkerMessage): void { queueMicrotask(() => this.emit('message', message)); } - - fail(error: Error): void { - queueMicrotask(() => this.emit('error', error)); - } - - exit(code: number): void { - queueMicrotask(() => this.emit('exit', code)); - } } const prompt: TelemetryConsentPrompt = { @@ -49,6 +43,19 @@ const prompt: TelemetryConsentPrompt = { learnMoreUrl: 'https://go.microsoft.com/fwlink/?linkid=521839', }; const promptJson = JSON.stringify(prompt); +const grantedStatusJson = + '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}'; +const withdrawnJson = + '{"result":"withdrawn","storedState":"denied","effectiveState":"denied","reason":null,"policy":"unrestricted"}'; + +function setAsyncImplementation( + overrides: Partial, +): void { + _setBindingTelemetryAsyncImplementation({ + readConsentStatusJson: overrides.readConsentStatusJson ?? (async () => grantedStatusJson), + withdrawConsentJson: overrides.withdrawConsentJson ?? (async () => withdrawnJson), + }); +} function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { const started = Date.now(); @@ -70,28 +77,16 @@ function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { describe('telemetry consent', () => { beforeEach(() => { _setTelemetryPlatform('win32'); + setAsyncImplementation({}); }); afterEach(() => { + _setBindingTelemetryAsyncImplementation(); _setBindingTelemetryWorkerFactory(); _setTelemetryPlatform(null); }); it('parses typed stored/effective status from a consistent native snapshot', async () => { - _setBindingTelemetryWorkerFactory(() => { - const worker = new FakeWorker(); - worker.reply({ - kind: 'snapshot', - snapshot: { - consent: 'granted', - statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', - policy: 'allowed', - needsPrompt: false, - }, - }); - return worker; - }); - assert.deepStrictEqual(await queryTelemetryConsentAsync(), { state: 'granted', storedState: 'granted', @@ -101,19 +96,9 @@ describe('telemetry consent', () => { }); }); - it('fails closed when the native snapshot is internally inconsistent', async () => { - _setBindingTelemetryWorkerFactory(() => { - const worker = new FakeWorker(); - worker.reply({ - kind: 'snapshot', - snapshot: { - consent: 'granted', - statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', - policy: 'blocked', - needsPrompt: false, - }, - }); - return worker; + it('fails closed when the native status payload is invalid', async () => { + setAsyncImplementation({ + readConsentStatusJson: async () => '{"storedState":"granted"}', }); const query = await queryTelemetryConsentAsync(); @@ -138,20 +123,9 @@ describe('telemetry consent', () => { let call = 0; console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' ')); try { - _setBindingTelemetryWorkerFactory(() => { - const worker = new FakeWorker(); - const statusJson = call === 0 ? 'not json' : '{"storedState":1}'; - call += 1; - worker.reply({ - kind: 'snapshot', - snapshot: { - consent: 'undetermined', - statusJson, - policy: 'blocked', - needsPrompt: false, - }, - }); - return worker; + setAsyncImplementation({ + readConsentStatusJson: async () => + call++ === 0 ? 'not json' : '{"storedState":1}', }); assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined'); assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'undetermined'); @@ -163,7 +137,7 @@ describe('telemetry consent', () => { }); it('maps a typed presenter decision onto the native callback result', async () => { - let requestData: TelemetryWorkerData | undefined; + let requestData: TelemetryRequestWorkerData | undefined; const worker = new FakeWorker(); _setBindingTelemetryWorkerFactory((data) => { requestData = data; @@ -174,8 +148,8 @@ describe('telemetry consent', () => { assert.deepStrictEqual(value, prompt); return 'yes'; }, 'en-US'); - await waitFor(() => requestData?.operation === 'request'); - const decision = new Int32Array((requestData as Extract).decisionShared); + await waitFor(() => requestData !== undefined); + const decision = new Int32Array(requestData!.decisionShared); worker.reply({ kind: 'present', promptJson }); await waitFor(() => Atomics.load(decision, 0) === 1); @@ -193,11 +167,11 @@ describe('telemetry consent', () => { needsPrompt: false, policy: 'allowed', }); - assert.strictEqual((requestData as Extract).locale, 'en-US'); + assert.strictEqual(requestData!.locale, 'en-US'); }); it('rejects invalid presenter decisions and returns the original failure', async () => { - let requestData: TelemetryWorkerData | undefined; + let requestData: TelemetryRequestWorkerData | undefined; const worker = new FakeWorker(); _setBindingTelemetryWorkerFactory((data) => { requestData = data; @@ -205,8 +179,8 @@ describe('telemetry consent', () => { }); const promise = requestTelemetryConsent(() => 'maybe' as unknown as 'yes'); - await waitFor(() => requestData?.operation === 'request'); - const decision = new Int32Array((requestData as Extract).decisionShared); + await waitFor(() => requestData !== undefined); + const decision = new Int32Array(requestData!.decisionShared); worker.reply({ kind: 'present', promptJson }); await waitFor(() => Atomics.load(decision, 0) === 1); @@ -232,30 +206,7 @@ describe('telemetry consent', () => { assert.strictEqual(called, false); }); - it('parses and withdraws through the worker-backed binding', async () => { - let call = 0; - _setBindingTelemetryWorkerFactory(() => { - const worker = new FakeWorker(); - if (call === 0) { - worker.reply({ - kind: 'snapshot', - snapshot: { - consent: 'granted', - statusJson: '{"storedState":"granted","effectiveState":"granted","reason":null,"policy":"allowed"}', - policy: 'allowed', - needsPrompt: false, - }, - }); - } else { - worker.reply({ - kind: 'payload', - payload: '{"result":"withdrawn","storedState":"denied","effectiveState":"denied","reason":null,"policy":"unrestricted"}', - }); - } - call += 1; - return worker; - }); - + it('parses and withdraws through the native binding', async () => { assert.strictEqual((await queryTelemetryConsentAsync()).effectiveState, 'granted'); assert.deepStrictEqual(await withdrawTelemetryConsentAsync(), { action: 'withdraw', @@ -268,13 +219,9 @@ describe('telemetry consent', () => { }); it('rejects withdrawal responses with invalid results', async () => { - _setBindingTelemetryWorkerFactory(() => { - const worker = new FakeWorker(); - worker.reply({ - kind: 'payload', - payload: '{"result":"status","storedState":"denied","effectiveState":"denied","reason":null,"policy":"blocked"}', - }); - return worker; + setAsyncImplementation({ + withdrawConsentJson: async () => + '{"result":"status","storedState":"denied","effectiveState":"denied","reason":null,"policy":"blocked"}', }); await assert.rejects(withdrawTelemetryConsentAsync(), /unrecognised telemetry consent output/); }); @@ -282,6 +229,7 @@ describe('telemetry consent', () => { describe('telemetry consent is Windows-only', () => { afterEach(() => { + _setBindingTelemetryAsyncImplementation(); _setBindingTelemetryWorkerFactory(); _setTelemetryPlatform(null); }); @@ -290,6 +238,16 @@ describe('telemetry consent is Windows-only', () => { it(`does not query or present consent on ${platform}`, async () => { _setTelemetryPlatform(platform); let called = false; + setAsyncImplementation({ + readConsentStatusJson: async () => { + called = true; + return grantedStatusJson; + }, + withdrawConsentJson: async () => { + called = true; + return withdrawnJson; + }, + }); _setBindingTelemetryWorkerFactory(() => { called = true; return new FakeWorker(); From 0a0848d6ee2bfb5643d7db3bd66e3cab538fc48a Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:49:21 -0700 Subject: [PATCH 09/11] Bound Node telemetry consent requests Restore a presenter-aware native request deadline, terminate stalled workers, cover worker failure paths, and correct native diagnostic documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- sdk/node/README.md | 4 +- .../src/bindings/telemetry-request-worker.ts | 47 ++++++++++ .../unit/telemetry-request-worker.test.ts | 85 +++++++++++++++++++ sdk/node/tests/unit/telemetry.test.ts | 2 + 4 files changed, 136 insertions(+), 2 deletions(-) diff --git a/sdk/node/README.md b/sdk/node/README.md index 5a8324ce3..8f5d3b16e 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -672,8 +672,8 @@ telemetry remains off. On non-Windows hosts requests and withdrawals return `queryTelemetryConsentAsync()` fails closed to `'undetermined'` rather than `'granted'`. Its `error` field is present when the native query fails or returns an invalid response. A valid native fail-closed response can return -`'undetermined'` or a blocked policy without `error`; any accompanying native -diagnostic is reported once through `console.warn`: +`'undetermined'` or a blocked policy without `error`; native diagnostics are +written to the process's standard error stream: ```typescript const { effectiveState, storedState, needsPrompt, policy, error } = diff --git a/sdk/node/src/bindings/telemetry-request-worker.ts b/sdk/node/src/bindings/telemetry-request-worker.ts index ef276e06c..1090168c0 100644 --- a/sdk/node/src/bindings/telemetry-request-worker.ts +++ b/sdk/node/src/bindings/telemetry-request-worker.ts @@ -21,10 +21,13 @@ export interface BindingTelemetryWorkerLike { on(event: 'message', listener: (message: TelemetryRequestWorkerMessage) => void): this; on(event: 'error', listener: (error: Error) => void): this; on(event: 'exit', listener: (code: number) => void): this; + terminate(): void; } type WorkerFactory = (data: TelemetryRequestWorkerData) => BindingTelemetryWorkerLike; +const DEFAULT_TELEMETRY_REQUEST_TIMEOUT_MS = 30_000; + const defaultWorkerFactory: WorkerFactory = (data) => new Worker( new URL('./telemetry-request-worker-entry.js', import.meta.url), { workerData: data, execArgv: [] }, @@ -43,6 +46,7 @@ function serializeUnknownError(error: unknown): Error { export function runTelemetryConsentRequestAsync( locale: string | undefined, presenter: (promptJson: string, signal: AbortSignal) => number | Promise, + timeoutMs = DEFAULT_TELEMETRY_REQUEST_TIMEOUT_MS, ): Promise { const decision = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 2)); return new Promise((resolve, reject) => { @@ -54,6 +58,9 @@ export function runTelemetryConsentRequestAsync( let presenterAbort: AbortController | null = null; let presenterError: Error | undefined; let decisionWritten = false; + let deadline: ReturnType | undefined; + let deadlineStartedAt = 0; + let deadlineRemainingMs = timeoutMs; const writeDecision = (code: number): void => { if (decisionWritten) { @@ -65,11 +72,30 @@ export function runTelemetryConsentRequestAsync( Atomics.notify(decision, 0); }; + const clearDeadline = (): void => { + if (deadline !== undefined) { + clearTimeout(deadline); + deadline = undefined; + } + }; + + const pauseDeadline = (): void => { + if (deadline === undefined) { + return; + } + deadlineRemainingMs = Math.max( + 0, + deadlineRemainingMs - (Date.now() - deadlineStartedAt), + ); + clearDeadline(); + }; + const finish = (action: () => void) => { if (settled) { return; } settled = true; + clearDeadline(); presenterAbort?.abort(); if (!decisionWritten) { writeDecision(TELEMETRY_CONSENT_PRESENTER_ERROR); @@ -77,8 +103,26 @@ export function runTelemetryConsentRequestAsync( action(); }; + const armDeadline = (): void => { + if (settled || deadline !== undefined) { + return; + } + deadlineStartedAt = Date.now(); + deadline = setTimeout(() => { + finish(() => { + worker.terminate(); + reject(new Error('telemetry consent request timed out')); + }); + }, deadlineRemainingMs); + }; + worker.on('message', (message) => { if (message.kind === 'present') { + if (presenterAbort !== null) { + finish(() => reject(new Error('telemetry request worker requested presentation twice'))); + return; + } + pauseDeadline(); presenterAbort = new AbortController(); void (async () => { try { @@ -90,6 +134,8 @@ export function runTelemetryConsentRequestAsync( } catch (error) { presenterError = serializeUnknownError(error); writeDecision(TELEMETRY_CONSENT_PRESENTER_ERROR); + } finally { + armDeadline(); } })(); return; @@ -115,5 +161,6 @@ export function runTelemetryConsentRequestAsync( code: 'backend_error', message: `telemetry worker exited before returning a result (code ${code})`, })))); + armDeadline(); }); } diff --git a/sdk/node/tests/unit/telemetry-request-worker.test.ts b/sdk/node/tests/unit/telemetry-request-worker.test.ts index 7607dd0fd..b5ea34fa1 100644 --- a/sdk/node/tests/unit/telemetry-request-worker.test.ts +++ b/sdk/node/tests/unit/telemetry-request-worker.test.ts @@ -18,13 +18,23 @@ import { } from '../../src/bindings/telemetry.js'; class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { + terminated = false; + reply(message: TelemetryRequestWorkerMessage): void { queueMicrotask(() => this.emit('message', message)); } + fail(error: Error): void { + queueMicrotask(() => this.emit('error', error)); + } + exit(code: number): void { queueMicrotask(() => this.emit('exit', code)); } + + terminate(): void { + this.terminated = true; + } } function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { @@ -136,4 +146,79 @@ describe('telemetry consent request worker', () => { assert.strictEqual(Atomics.load(decision, 0), 1); assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); }); + + it('aborts the presenter and preserves a worker error', async () => { + let observedSignal: AbortSignal | undefined; + const promise = runTelemetryConsentRequestAsync(undefined, async (_promptJson, signal) => { + observedSignal = signal; + await new Promise(() => {}); + return TELEMETRY_CONSENT_DECISION_YES; + }); + await waitFor(() => workerData !== undefined); + const decision = new Int32Array(workerData!.decisionShared); + + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', + }); + await waitFor(() => observedSignal !== undefined); + + worker.fail(new Error('worker crashed')); + + await assert.rejects(promise, /worker crashed/); + assert.strictEqual(observedSignal?.aborted, true); + assert.strictEqual(Atomics.load(decision, 0), 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); + }); + + it('rejects unexpected worker messages', async () => { + const promise = runTelemetryConsentRequestAsync( + undefined, + () => TELEMETRY_CONSENT_DECISION_YES, + ); + await waitFor(() => workerData !== undefined); + + worker.reply({ kind: 'unknown' } as unknown as TelemetryRequestWorkerMessage); + + await assert.rejects(promise, /unexpected message/); + }); + + it('terminates a worker that stops making native progress', async () => { + const promise = runTelemetryConsentRequestAsync( + undefined, + () => TELEMETRY_CONSENT_DECISION_YES, + 10, + ); + await waitFor(() => workerData !== undefined); + + await assert.rejects(promise, /timed out/); + assert.strictEqual(worker.terminated, true); + }); + + it('pauses the native deadline while the presenter is deciding', async () => { + let resolvePresenter: ((decision: number) => void) | undefined; + const promise = runTelemetryConsentRequestAsync( + undefined, + () => new Promise((resolve) => { + resolvePresenter = resolve; + }), + 100, + ); + await waitFor(() => workerData !== undefined); + + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', + }); + await waitFor(() => resolvePresenter !== undefined); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.strictEqual(worker.terminated, false); + + resolvePresenter!(TELEMETRY_CONSENT_DECISION_YES); + const decision = new Int32Array(workerData!.decisionShared); + await waitFor(() => Atomics.load(decision, 0) === 1); + worker.reply({ kind: 'payload', payload: '{"result":"granted"}' }); + + assert.strictEqual(await promise, '{"result":"granted"}'); + }); }); diff --git a/sdk/node/tests/unit/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts index f52b0d05d..beadb62d4 100644 --- a/sdk/node/tests/unit/telemetry.test.ts +++ b/sdk/node/tests/unit/telemetry.test.ts @@ -30,6 +30,8 @@ class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { reply(message: TelemetryRequestWorkerMessage): void { queueMicrotask(() => this.emit('message', message)); } + + terminate(): void {} } const prompt: TelemetryConsentPrompt = { From 8bce1674cb7526045df5eed0f3b0ee82a387bc73 Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:35:17 -0700 Subject: [PATCH 10/11] Harden Node telemetry consent completion Fail closed before committing expired presenter decisions, abandon timed-out native workers without holding process exit, preserve typed withdrawal failures, and align telemetry documentation and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- sdk/node/README.md | 8 ++--- sdk/node/src/bindings/native-error.ts | 2 ++ .../src/bindings/telemetry-request-worker.ts | 20 +++++++++---- sdk/node/src/telemetry.ts | 4 +++ sdk/node/tests/unit/binding-run.test.ts | 1 + .../unit/telemetry-request-worker.test.ts | 30 +++++++++++++++++++ sdk/node/tests/unit/telemetry.test.ts | 21 +++++++++++++ 7 files changed, 77 insertions(+), 9 deletions(-) diff --git a/sdk/node/README.md b/sdk/node/README.md index 8f5d3b16e..35df32921 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -594,7 +594,7 @@ getUserProfilePolicy() → FilesystemPolicyResult getTemporaryFilesPolicy(env?) → FilesystemPolicyResult // Telemetry consent (Windows-only; see Telemetry Consent section below) -queryTelemetryConsentAsync() → Promise<{ storedState, effectiveState, needsPrompt, policy, error? }> +queryTelemetryConsentAsync() → Promise<{ state, storedState, effectiveState, needsPrompt, policy, error? }> requestTelemetryConsent(presenter, locale?) → Promise withdrawTelemetryConsentAsync() → Promise @@ -671,9 +671,9 @@ telemetry remains off. On non-Windows hosts requests and withdrawals return `queryTelemetryConsentAsync()` fails closed to `'undetermined'` rather than `'granted'`. Its `error` field is present when the native query fails or -returns an invalid response. A valid native fail-closed response can return -`'undetermined'` or a blocked policy without `error`; native diagnostics are -written to the process's standard error stream: +returns an invalid response, and the SDK writes a one-time diagnostic to the +process's standard error stream. A valid native fail-closed response can return +`'undetermined'` or a blocked policy without `error` or diagnostic output: ```typescript const { effectiveState, storedState, needsPrompt, policy, error } = diff --git a/sdk/node/src/bindings/native-error.ts b/sdk/node/src/bindings/native-error.ts index 8da1e5770..89d21729a 100644 --- a/sdk/node/src/bindings/native-error.ts +++ b/sdk/node/src/bindings/native-error.ts @@ -44,6 +44,8 @@ export function _errorCodeForNativeStatus(status: number): ErrorCode { 12: 'backend_error', 100: 'malformed_request', 101: 'malformed_request', + 102: 'backend_error', + 103: 'backend_error', }; return codes[status] ?? 'backend_error'; } diff --git a/sdk/node/src/bindings/telemetry-request-worker.ts b/sdk/node/src/bindings/telemetry-request-worker.ts index 1090168c0..6acb7c05a 100644 --- a/sdk/node/src/bindings/telemetry-request-worker.ts +++ b/sdk/node/src/bindings/telemetry-request-worker.ts @@ -21,6 +21,7 @@ export interface BindingTelemetryWorkerLike { on(event: 'message', listener: (message: TelemetryRequestWorkerMessage) => void): this; on(event: 'error', listener: (error: Error) => void): this; on(event: 'exit', listener: (code: number) => void): this; + unref(): void; terminate(): void; } @@ -107,9 +108,18 @@ export function runTelemetryConsentRequestAsync( if (settled || deadline !== undefined) { return; } + if (deadlineRemainingMs <= 0) { + finish(() => { + worker.unref(); + worker.terminate(); + reject(new Error('telemetry consent request timed out')); + }); + return; + } deadlineStartedAt = Date.now(); deadline = setTimeout(() => { finish(() => { + worker.unref(); worker.terminate(); reject(new Error('telemetry consent request timed out')); }); @@ -125,18 +135,18 @@ export function runTelemetryConsentRequestAsync( pauseDeadline(); presenterAbort = new AbortController(); void (async () => { + let code = TELEMETRY_CONSENT_PRESENTER_ERROR; try { - const code = await presenter(message.promptJson, presenterAbort.signal); + code = await presenter(message.promptJson, presenterAbort.signal); if (!Number.isSafeInteger(code)) { throw new Error(`consent presenter returned invalid decision '${String(code)}'`); } - writeDecision(code); } catch (error) { presenterError = serializeUnknownError(error); - writeDecision(TELEMETRY_CONSENT_PRESENTER_ERROR); - } finally { - armDeadline(); + code = TELEMETRY_CONSENT_PRESENTER_ERROR; } + armDeadline(); + writeDecision(code); })(); return; } diff --git a/sdk/node/src/telemetry.ts b/sdk/node/src/telemetry.ts index d7460411a..38d16d4e4 100644 --- a/sdk/node/src/telemetry.ts +++ b/sdk/node/src/telemetry.ts @@ -9,6 +9,7 @@ import { withdrawTelemetryConsentJsonAsync, } from './bindings/telemetry.js'; import { runTelemetryConsentRequestAsync } from './bindings/telemetry-request-worker.js'; +import { MxcError } from './errors.js'; const TELEMETRY_CONSENT_STATES = ['granted', 'denied', 'undetermined', 'not-applicable'] as const; const TELEMETRY_POLICY_STATES = ['unrestricted', 'allowed', 'blocked', 'not-applicable'] as const; @@ -373,6 +374,9 @@ export async function withdrawTelemetryConsentAsync(): Promise { assert.strictEqual(_errorCodeForNativeStatus(100), 'malformed_request'); assert.strictEqual(_errorCodeForNativeStatus(101), 'malformed_request'); assert.strictEqual(_errorCodeForNativeStatus(102), 'backend_error'); + assert.strictEqual(_errorCodeForNativeStatus(103), 'backend_error'); assert.strictEqual(_errorCodeForNativeStatus(999), 'backend_error'); }); }); diff --git a/sdk/node/tests/unit/telemetry-request-worker.test.ts b/sdk/node/tests/unit/telemetry-request-worker.test.ts index b5ea34fa1..d1bf6e1a9 100644 --- a/sdk/node/tests/unit/telemetry-request-worker.test.ts +++ b/sdk/node/tests/unit/telemetry-request-worker.test.ts @@ -19,6 +19,7 @@ import { class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { terminated = false; + unreferenced = false; reply(message: TelemetryRequestWorkerMessage): void { queueMicrotask(() => this.emit('message', message)); @@ -32,6 +33,10 @@ class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { queueMicrotask(() => this.emit('exit', code)); } + unref(): void { + this.unreferenced = true; + } + terminate(): void { this.terminated = true; } @@ -193,6 +198,7 @@ describe('telemetry consent request worker', () => { await assert.rejects(promise, /timed out/); assert.strictEqual(worker.terminated, true); + assert.strictEqual(worker.unreferenced, true); }); it('pauses the native deadline while the presenter is deciding', async () => { @@ -221,4 +227,28 @@ describe('telemetry consent request worker', () => { assert.strictEqual(await promise, '{"result":"granted"}'); }); + + it('fails closed before committing a decision after the deadline is exhausted', async (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + const promise = runTelemetryConsentRequestAsync( + undefined, + () => TELEMETRY_CONSENT_DECISION_YES, + 100, + ); + const rejection = assert.rejects(promise, /timed out/); + await waitFor(() => workerData !== undefined); + + t.mock.timers.tick(100); + worker.reply({ + kind: 'present', + promptJson: '{"resourceVersion":1,"locale":"en-US","title":{"id":"title","text":"Help improve MXC"},"body":{"id":"body","text":"body"},"affirmativeLabel":{"id":"yes","text":"Yes"},"negativeLabel":{"id":"no","text":"No"},"learnMoreLabel":{"id":"learn","text":"Learn more"},"learnMoreUrl":"https://example.microsoft.com/privacy"}', + }); + + const decision = new Int32Array(workerData!.decisionShared); + await waitFor(() => Atomics.load(decision, 0) === 1); + assert.strictEqual(Atomics.load(decision, 1), TELEMETRY_CONSENT_PRESENTER_ERROR); + await rejection; + assert.strictEqual(worker.terminated, true); + assert.strictEqual(worker.unreferenced, true); + }); }); diff --git a/sdk/node/tests/unit/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts index beadb62d4..fbef12613 100644 --- a/sdk/node/tests/unit/telemetry.test.ts +++ b/sdk/node/tests/unit/telemetry.test.ts @@ -25,12 +25,15 @@ import { type TelemetryRequestWorkerData, type TelemetryRequestWorkerMessage, } from '../../src/bindings/telemetry-request-worker.js'; +import { MxcError } from '../../src/errors.js'; class FakeWorker extends EventEmitter implements BindingTelemetryWorkerLike { reply(message: TelemetryRequestWorkerMessage): void { queueMicrotask(() => this.emit('message', message)); } + unref(): void {} + terminate(): void {} } @@ -227,6 +230,24 @@ describe('telemetry consent', () => { }); await assert.rejects(withdrawTelemetryConsentAsync(), /unrecognised telemetry consent output/); }); + + it('preserves typed native withdrawal failures', async () => { + const nativeError = new MxcError({ + code: 'backend_error', + message: 'consent store write failed', + details: { ffiStatus: 103 }, + }); + setAsyncImplementation({ + withdrawConsentJson: async () => { + throw nativeError; + }, + }); + + await assert.rejects( + withdrawTelemetryConsentAsync(), + (error) => error === nativeError, + ); + }); }); describe('telemetry consent is Windows-only', () => { From e5a21a8e17b1f1c7c303418199efa5d41d5074ee Mon Sep 17 00:00:00 2001 From: Branden Bonaby <105318831+bbonaby@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:11:05 -0700 Subject: [PATCH 11/11] Preserve non-Windows telemetry behavior Return notApplicable before locale validation on unsupported platforms and update the telemetry parity checker for Node's removal of private executor-protocol results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bbe1ffbf-7cf8-48a0-8e3b-956f1d634e6c --- scripts/check-telemetry-policy-parity.js | 5 ----- sdk/node/src/telemetry.ts | 2 +- sdk/node/tests/unit/telemetry.test.ts | 11 +++++++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/scripts/check-telemetry-policy-parity.js b/scripts/check-telemetry-policy-parity.js index 1bf077a6b..9a3e295fd 100644 --- a/scripts/check-telemetry-policy-parity.js +++ b/scripts/check-telemetry-policy-parity.js @@ -191,11 +191,6 @@ const protocolOnlyResults = new Set( const terminalResults = new Set( [...protocolResults].filter((value) => !protocolOnlyResults.has(value)) ); -compareSets( - "TypeScript private protocol result", - protocolOnlyResults, - typescriptValues("CONSENT_PROTOCOL_ONLY_RESULTS") -); compareSets( "TypeScript consent result", terminalResults, diff --git a/sdk/node/src/telemetry.ts b/sdk/node/src/telemetry.ts index 38d16d4e4..438671237 100644 --- a/sdk/node/src/telemetry.ts +++ b/sdk/node/src/telemetry.ts @@ -354,10 +354,10 @@ export async function requestTelemetryConsent( presenter: TelemetryConsentPresenter, locale?: string, ): Promise { - validateLocale(locale); if (!isWindows()) { return notApplicable('request'); } + validateLocale(locale); const json = await runTelemetryConsentRequestAsync( locale, (promptJson, signal) => presentConsentDecision(presenter, promptJson, signal), diff --git a/sdk/node/tests/unit/telemetry.test.ts b/sdk/node/tests/unit/telemetry.test.ts index fbef12613..6cc69fd77 100644 --- a/sdk/node/tests/unit/telemetry.test.ts +++ b/sdk/node/tests/unit/telemetry.test.ts @@ -276,10 +276,13 @@ describe('telemetry consent is Windows-only', () => { return new FakeWorker(); }); - const request = await requestTelemetryConsent(() => { - called = true; - return 'yes'; - }); + const request = await requestTelemetryConsent( + () => { + called = true; + return 'yes'; + }, + 'en-US\0dev', + ); assert.strictEqual(request.result, 'notApplicable'); assert.strictEqual((await queryTelemetryConsentAsync()).state, 'not-applicable'); assert.strictEqual((await withdrawTelemetryConsentAsync()).result, 'notApplicable');