Skip to content

Commit a389e2f

Browse files
committed
feat(theia): fail loudly on bridge failure when DATA_BRIDGE_ENABLED is set
readEnvVarsViaDataBridge now returns a discriminated union instead of `Record | undefined`: - 'no-bridge' : DATA_BRIDGE_ENABLED unset (genuine Desktop) - 'success' : all keys delivered - 'failure' : bridge expected but unreachable (command-missing / timeout / invalid-response) detectTheiaEnvironment dispatches on this union: 'failure' surfaces showErrorMessage and falls back to Desktop so the diagnostic command remains accessible. Previously every non-success collapsed to silent Desktop, which booted EduIDE pods with Cookie auth (wrong scheme against an EduIDE Artemis) when the bridge was slow or absent. Adds 11 unit tests covering all four return shapes plus the fail-loud branches in the detector. No behaviour change for genuine Desktop boots.
1 parent 90e90a8 commit a389e2f

4 files changed

Lines changed: 328 additions & 22 deletions

File tree

extension/src/extension/theia/dataBridgeReader.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ export const KNOWN_BRIDGE_KEYS = [
2626

2727
type KnownBridgeKey = (typeof KNOWN_BRIDGE_KEYS)[number];
2828

29+
/**
30+
* Outcome of a {@link readEnvVarsViaDataBridge} call.
31+
*
32+
* The discriminator distinguishes two situations that both used to be encoded
33+
* as `undefined`: a genuine Desktop boot (`no-bridge`) and an EduIDE boot where
34+
* the bridge was expected but unreachable (`failure`). Conflating them lets a
35+
* broken EduIDE pod silently boot in Desktop-Cookie mode, which is the wrong
36+
* failure mode (auth would attempt the wrong scheme; auto-clone would not run).
37+
*/
38+
export type ReadEnvResult<T extends string> =
39+
| { kind: 'no-bridge' }
40+
| { kind: 'success'; env: Record<T, string | undefined> }
41+
| { kind: 'failure'; reason: 'command-missing' | 'timeout' | 'invalid-response'; details?: string };
42+
2943
/**
3044
* Reads environment variables via the EduIDE data-bridge companion extension.
3145
*
@@ -40,66 +54,77 @@ type KnownBridgeKey = (typeof KNOWN_BRIDGE_KEYS)[number];
4054
* data, which would cause a 10s blocking poll on every startup.
4155
*
4256
* Polls every 500ms until all requested keys are present, with a 10s timeout.
43-
* Returns `undefined` if data-bridge is unavailable or times out — the caller
44-
* treats this as "not running in Theia/EduIDE" and falls back to the
45-
* non-Theia default environment.
57+
*
58+
* Return semantics:
59+
* - `no-bridge`: `DATA_BRIDGE_ENABLED` is not set → genuinely Desktop.
60+
* - `failure`: bridge was expected (`DATA_BRIDGE_ENABLED=1`) but the command
61+
* is missing or did not deliver values within the timeout → caller MUST
62+
* surface this loudly; silently degrading to Desktop produces broken auth.
63+
* - `success`: all requested keys delivered.
4664
*
4765
* Pattern adapted from Scorpio's DataBridgeStrategy (env-strategy.ts).
4866
*/
4967
export async function readEnvVarsViaDataBridge<T extends string>(
5068
names: readonly T[],
51-
): Promise<Record<T, string | undefined> | undefined> {
69+
): Promise<ReadEnvResult<T>> {
5270
// DATA_BRIDGE_ENABLED is a container-boot config var, available in process.env
5371
// from container startup — unlike the credentials (ARTEMIS_TOKEN etc.) which are
5472
// injected later via the data-bridge HTTP server. This is why process.env is
5573
// reliable here even though it's unreliable for late-arriving credentials.
5674
// Without this guard, polling would block for 10s with no data arriving.
5775
const bridgeEnabled = process.env.DATA_BRIDGE_ENABLED;
5876
if (bridgeEnabled !== '1' && bridgeEnabled !== 'true') {
59-
return undefined;
77+
return { kind: 'no-bridge' };
6078
}
6179

6280
const commands = await vscode.commands.getCommands(true);
6381
if (!commands.includes(DATA_BRIDGE_COMMAND)) {
64-
return undefined;
82+
return { kind: 'failure', reason: 'command-missing' };
6583
}
6684

6785
logger.info('DataBridge enabled, polling for environment variables...', LogCategory.GENERAL);
6886
const deadline = Date.now() + POLL_TIMEOUT_MS;
87+
let lastInvalidResponse: string | undefined;
6988

7089
while (Date.now() < deadline) {
7190
try {
7291
// The data-bridge `getEnv` command requires a `string[]` argument
7392
// (validated server-side via arktype). Calling without args makes
7493
// the command return its error summary as a plain string, not a
7594
// record — which silently masquerades as an empty result.
76-
const envMap = await vscode.commands.executeCommand<Record<string, string>>(
95+
const envMap = await vscode.commands.executeCommand<Record<string, string> | string>(
7796
DATA_BRIDGE_COMMAND,
7897
[...names],
7998
);
8099

81100
if (envMap && typeof envMap === 'object') {
82-
if (names.every((name) => !!envMap[name])) {
101+
if (names.every((name) => !!(envMap as Record<string, string>)[name])) {
83102
logger.info('DataBridge: all environment variables received', LogCategory.GENERAL);
84103
const result = {} as Record<T, string | undefined>;
85104
for (const name of names) {
86-
result[name] = envMap[name] || undefined;
105+
result[name] = (envMap as Record<string, string>)[name] || undefined;
87106
}
88-
return result;
107+
return { kind: 'success', env: result };
89108
}
109+
} else if (typeof envMap === 'string') {
110+
// Bridge returned its error summary string — keep the latest
111+
// for diagnostics if the deadline runs out before recovery.
112+
lastInvalidResponse = envMap;
90113
}
91-
} catch {
92-
// data-bridge may not be ready yet — keep polling
114+
} catch (e) {
115+
// data-bridge may not be ready yet — keep polling. Capture the
116+
// most recent error to surface if the deadline elapses.
117+
lastInvalidResponse = e instanceof Error ? e.message : String(e);
93118
}
94119

95120
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
96121
}
97122

98123
logger.warn(
99-
'DataBridge: timeout waiting for environment variables, treating session as non-Theia',
124+
'DataBridge: timeout waiting for environment variables',
100125
LogCategory.GENERAL,
101126
);
102-
return undefined;
127+
return { kind: 'failure', reason: 'timeout', details: lastInvalidResponse };
103128
}
104129

105130
interface DataBridgeProbeResult {

extension/src/extension/theia/theiaEnvironment.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import * as vscode from 'vscode';
12
import { readEnvVarsViaDataBridge } from './dataBridgeReader';
3+
import { logger, LogCategory } from '../services/loggingService';
24
import { VSCODE_ENVIRONMENT, type TheiaEnvironment } from './types';
35

46
/**
@@ -43,17 +45,56 @@ export function getTheiaEnvironment(): TheiaEnvironment {
4345
* Detects whether the extension is running inside a Theia-based IDE
4446
* and reads all relevant environment variables.
4547
*
46-
* The data-bridge is the sole source of truth: in EduIDE deployments the
47-
* orchestrator POSTs credentials into the bridge after pod boot, and the
48-
* bridge command is only registered when its activation gate
49-
* (`DATA_BRIDGE_ENABLED=1`) is set. On regular VS Code Desktop neither is
50-
* true, so the call returns undefined and detection cleanly resolves to
51-
* the non-Theia default.
48+
* The data-bridge is the sole source of truth. The reader distinguishes three
49+
* outcomes; each maps to a different boot path here:
50+
* - `no-bridge`: `DATA_BRIDGE_ENABLED` not set → genuinely Desktop. Return
51+
* the non-Theia default; Cookie auth and interactive login are correct.
52+
* - `failure`: bridge was expected but unreachable. Surface a hard error so
53+
* the operator and the student see why authentication will not work,
54+
* instead of silently booting in Desktop-Cookie mode against an EduIDE
55+
* Artemis (which would then attempt the wrong auth scheme and fail with
56+
* a confusing 401). The extension still loads so the diagnostic command
57+
* remains accessible.
58+
* - `success`: bridge delivered all keys → Theia-managed environment.
5259
*/
5360
async function detectTheiaEnvironment(): Promise<TheiaEnvironment> {
54-
const env = await readEnvVarsViaDataBridge(THEIA_ENV_VARS);
61+
const result = await readEnvVarsViaDataBridge(THEIA_ENV_VARS);
5562

56-
if (!env || !(env.ARTEMIS_TOKEN || env.ARTEMIS_URL)) {
63+
if (result.kind === 'no-bridge') {
64+
return VSCODE_ENVIRONMENT;
65+
}
66+
67+
if (result.kind === 'failure') {
68+
const reasonText =
69+
result.reason === 'command-missing'
70+
? 'data-bridge extension not registered'
71+
: result.reason === 'timeout'
72+
? 'timed out waiting for credentials'
73+
: 'invalid response';
74+
const detailsSuffix = result.details ? ` (${result.details})` : '';
75+
logger.error(
76+
`EduIDE bridge unavailable: ${reasonText}${detailsSuffix}`,
77+
LogCategory.GENERAL,
78+
);
79+
void vscode.window.showErrorMessage(
80+
`Iris: EduIDE bridge unavailable (${reasonText}). Authentication and auto-clone will not work. ` +
81+
'Restart your EduIDE pod or contact support.',
82+
);
83+
return VSCODE_ENVIRONMENT;
84+
}
85+
86+
const env = result.env;
87+
if (!env.ARTEMIS_TOKEN || !env.ARTEMIS_URL) {
88+
// Bridge responded but did not deliver the auth pair — same hard
89+
// failure as `failure` from the caller's perspective.
90+
logger.error(
91+
'EduIDE bridge response missing ARTEMIS_URL or ARTEMIS_TOKEN',
92+
LogCategory.GENERAL,
93+
);
94+
void vscode.window.showErrorMessage(
95+
'Iris: EduIDE bridge returned an incomplete credential set. Authentication will not work. ' +
96+
'Restart your EduIDE pod or contact support.',
97+
);
5798
return VSCODE_ENVIRONMENT;
5899
}
59100

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Unit tests for readEnvVarsViaDataBridge.
3+
*
4+
* Verifies the discriminated-union return contract:
5+
* - DATA_BRIDGE_ENABLED unset → 'no-bridge'
6+
* - DATA_BRIDGE_ENABLED=1 + command not registered → 'failure: command-missing'
7+
* - DATA_BRIDGE_ENABLED=1 + command never delivers → 'failure: timeout'
8+
* - DATA_BRIDGE_ENABLED=1 + command returns full record → 'success'
9+
* - DATA_BRIDGE_ENABLED=1 + command returns partial record → eventually
10+
* 'failure: timeout' (waits for all keys)
11+
*/
12+
13+
import * as assert from 'assert';
14+
import * as sinon from 'sinon';
15+
import * as vscode from 'vscode';
16+
import { readEnvVarsViaDataBridge } from '../../../src/extension/theia/dataBridgeReader';
17+
18+
const KEYS = ['ARTEMIS_URL', 'ARTEMIS_TOKEN'] as const;
19+
20+
suite('readEnvVarsViaDataBridge', () => {
21+
let sandbox: sinon.SinonSandbox;
22+
let originalDataBridgeEnabled: string | undefined;
23+
24+
setup(() => {
25+
sandbox = sinon.createSandbox();
26+
originalDataBridgeEnabled = process.env.DATA_BRIDGE_ENABLED;
27+
});
28+
29+
teardown(() => {
30+
sandbox.restore();
31+
if (originalDataBridgeEnabled === undefined) {
32+
delete process.env.DATA_BRIDGE_ENABLED;
33+
} else {
34+
process.env.DATA_BRIDGE_ENABLED = originalDataBridgeEnabled;
35+
}
36+
});
37+
38+
test('returns no-bridge when DATA_BRIDGE_ENABLED is unset', async () => {
39+
delete process.env.DATA_BRIDGE_ENABLED;
40+
41+
const result = await readEnvVarsViaDataBridge(KEYS);
42+
43+
assert.strictEqual(result.kind, 'no-bridge');
44+
});
45+
46+
test('returns no-bridge when DATA_BRIDGE_ENABLED is "false"', async () => {
47+
process.env.DATA_BRIDGE_ENABLED = 'false';
48+
49+
const result = await readEnvVarsViaDataBridge(KEYS);
50+
51+
assert.strictEqual(result.kind, 'no-bridge');
52+
});
53+
54+
test('returns failure with reason=command-missing when bridge command is not registered', async () => {
55+
process.env.DATA_BRIDGE_ENABLED = '1';
56+
sandbox.stub(vscode.commands, 'getCommands').resolves(['some.other.command']);
57+
58+
const result = await readEnvVarsViaDataBridge(KEYS);
59+
60+
assert.strictEqual(result.kind, 'failure');
61+
if (result.kind === 'failure') {
62+
assert.strictEqual(result.reason, 'command-missing');
63+
}
64+
});
65+
66+
test('returns failure with reason=timeout when bridge command never delivers values', async () => {
67+
process.env.DATA_BRIDGE_ENABLED = '1';
68+
sandbox.stub(vscode.commands, 'getCommands').resolves(['dataBridge.getEnv']);
69+
sandbox.stub(vscode.commands, 'executeCommand').resolves({}); // empty record forever
70+
71+
// Drive timeout deterministically by stubbing Date.now to leap past the 10s deadline.
72+
const startTime = 1_000_000_000;
73+
let nowOffset = 0;
74+
sandbox.stub(Date, 'now').callsFake(() => startTime + nowOffset);
75+
// First poll attempt sees deadline = start + 10000. Bump offset past that on the
76+
// second call so the while-loop exits via deadline check.
77+
const setTimeoutStub = sandbox.stub(global, 'setTimeout').callsFake((fn: TimerHandler) => {
78+
nowOffset += 11_000; // jump past deadline before the next iteration runs
79+
if (typeof fn === 'function') { (fn as () => void)(); }
80+
return 0 as unknown as NodeJS.Timeout;
81+
});
82+
83+
const result = await readEnvVarsViaDataBridge(KEYS);
84+
85+
assert.strictEqual(result.kind, 'failure');
86+
if (result.kind === 'failure') {
87+
assert.strictEqual(result.reason, 'timeout');
88+
}
89+
// Polling happened at least once
90+
assert.ok(setTimeoutStub.callCount >= 1);
91+
});
92+
93+
test('returns failure with reason=timeout and details when bridge returns its error string', async () => {
94+
process.env.DATA_BRIDGE_ENABLED = '1';
95+
sandbox.stub(vscode.commands, 'getCommands').resolves(['dataBridge.getEnv']);
96+
sandbox.stub(vscode.commands, 'executeCommand').resolves('arktype validation error: expected string[]');
97+
98+
const startTime = 1_000_000_000;
99+
let nowOffset = 0;
100+
sandbox.stub(Date, 'now').callsFake(() => startTime + nowOffset);
101+
sandbox.stub(global, 'setTimeout').callsFake((fn: TimerHandler) => {
102+
nowOffset += 11_000;
103+
if (typeof fn === 'function') { (fn as () => void)(); }
104+
return 0 as unknown as NodeJS.Timeout;
105+
});
106+
107+
const result = await readEnvVarsViaDataBridge(KEYS);
108+
109+
assert.strictEqual(result.kind, 'failure');
110+
if (result.kind === 'failure') {
111+
assert.strictEqual(result.reason, 'timeout');
112+
assert.ok(result.details && result.details.includes('arktype'),
113+
`expected details to include the bridge error string, got: ${result.details}`);
114+
}
115+
});
116+
117+
test('returns success with all requested keys when bridge delivers them', async () => {
118+
process.env.DATA_BRIDGE_ENABLED = '1';
119+
sandbox.stub(vscode.commands, 'getCommands').resolves(['dataBridge.getEnv']);
120+
sandbox.stub(vscode.commands, 'executeCommand').resolves({
121+
ARTEMIS_URL: 'https://artemis.test',
122+
ARTEMIS_TOKEN: 'jwt-abc',
123+
});
124+
125+
const result = await readEnvVarsViaDataBridge(KEYS);
126+
127+
assert.strictEqual(result.kind, 'success');
128+
if (result.kind === 'success') {
129+
assert.strictEqual(result.env.ARTEMIS_URL, 'https://artemis.test');
130+
assert.strictEqual(result.env.ARTEMIS_TOKEN, 'jwt-abc');
131+
}
132+
});
133+
});

0 commit comments

Comments
 (0)