Skip to content

Commit 2eb1576

Browse files
author
e2e
committed
fix(windows): handle console readiness and isolated runtime setup
1 parent 922b3a3 commit 2eb1576

7 files changed

Lines changed: 424 additions & 36 deletions

File tree

.github/workflows/windows.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,30 @@ jobs:
6767
npm run check:licenses
6868
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
6969
70+
- name: Test Windows packaging helpers
71+
id: packaging
72+
run: node --test scripts/fetch-bun.test.mjs scripts/release/windows-payload.test.mjs scripts/release/windows-smoke-environment.test.mjs
73+
7074
- name: Verify Rust core
71-
run: npm run check:core
75+
id: core
76+
if: ${{ !cancelled() && steps.source.outcome == 'success' }}
77+
run: |
78+
npm run check:core 2>&1 | Tee-Object -FilePath (Join-Path $env:RUNNER_TEMP 'gajae-core-windows.log')
79+
exit $LASTEXITCODE
80+
81+
- name: Upload failed core diagnostics
82+
if: ${{ !cancelled() && steps.core.outcome == 'failure' }}
83+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
84+
with:
85+
name: windows-core-diagnostics
86+
path: ${{ runner.temp }}/gajae-core-windows.log
87+
retention-days: 7
7288

7389
- name: Build payload and installer
7490
id: build
7591
# Gather independent Windows failures in one run. A failed core check
7692
# still fails the job and prevents the final artifact upload.
77-
if: ${{ !cancelled() && steps.source.outcome == 'success' }}
93+
if: ${{ !cancelled() && steps.source.outcome == 'success' && steps.packaging.outcome == 'success' }}
7894
run: npm run desktop:build:windows
7995

8096
- name: Test Windows runtime

native/gajae-core/tests/process_protocol.rs

Lines changed: 155 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::io::{BufRead, BufReader, Read, Write};
22
use std::path::PathBuf;
33
use std::process::{Child, Command, ExitStatus, Stdio};
4-
use std::sync::mpsc;
4+
use std::sync::{Arc, Mutex, mpsc};
55
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
66

77
use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -28,7 +28,16 @@ impl TestDirectory {
2828

2929
impl Drop for TestDirectory {
3030
fn drop(&mut self) {
31-
std::fs::remove_dir_all(&self.0).unwrap();
31+
// Windows may briefly retain the copied fixture executable while
32+
// ConPTY exits. Never double-panic during a timeout's stack unwind.
33+
for _ in 0..20 {
34+
match std::fs::remove_dir_all(&self.0) {
35+
Ok(()) => return,
36+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
37+
Err(_) => std::thread::sleep(Duration::from_millis(50)),
38+
}
39+
}
40+
let _ = writeln!(std::io::stderr(), "fixture cleanup failed: {:?}", self.0);
3241
}
3342
}
3443

@@ -52,6 +61,15 @@ impl CoreChild {
5261

5362
impl Drop for CoreChild {
5463
fn drop(&mut self) {
64+
// Closing input lets the core kill/reap its own PTY child and close
65+
// ConPTY before the executable's directory is removed.
66+
self.0.stdin.take();
67+
for _ in 0..100 {
68+
if matches!(self.0.try_wait(), Ok(Some(_))) {
69+
return;
70+
}
71+
std::thread::sleep(Duration::from_millis(10));
72+
}
5573
let _ = self.0.kill();
5674
let _ = self.0.wait();
5775
}
@@ -106,6 +124,10 @@ fn child_fixture() {
106124
std::io::stdout().flush().unwrap();
107125
std::process::exit(23);
108126
}
127+
// The protocol's ready frame means the PTY exists, not that its child has
128+
// finished console initialization. On ConPTY a cursor query can precede it.
129+
writeln!(std::io::stdout().lock(), "fixture-ready").unwrap();
130+
std::io::stdout().flush().unwrap();
109131
let stdin = std::io::stdin();
110132
for line in stdin.lock().lines() {
111133
// Input follows resize, so ConPTY cannot wrap the long path at its
@@ -119,6 +141,73 @@ fn child_fixture() {
119141
}
120142
}
121143

144+
#[derive(Default)]
145+
struct TerminalOutput {
146+
bytes: Vec<u8>,
147+
answered_cursor_queries: usize,
148+
}
149+
150+
impl TerminalOutput {
151+
fn push(&mut self, bytes: &[u8]) -> usize {
152+
self.bytes.extend_from_slice(bytes);
153+
// portable-pty uses PSEUDOCONSOLE_INHERIT_CURSOR. A real terminal
154+
// answers CSI 6 n; ignoring it can deadlock ResizePseudoConsole.
155+
// Count over the accumulated bytes to handle split output frames.
156+
let queries = self
157+
.bytes
158+
.windows(4)
159+
.filter(|part| *part == b"\x1b[6n")
160+
.count();
161+
let pending = queries - self.answered_cursor_queries;
162+
self.answered_cursor_queries = queries;
163+
pending
164+
}
165+
166+
fn contains(&self, text: &str) -> bool {
167+
self.bytes
168+
.windows(text.len())
169+
.any(|part| part == text.as_bytes())
170+
}
171+
}
172+
173+
fn write_request(input: &mut impl Write, request: Value) {
174+
writeln!(input, "{request}").unwrap();
175+
input.flush().unwrap();
176+
}
177+
178+
fn receive_frame(
179+
receiver: &mpsc::Receiver<Result<Value, String>>,
180+
deadline: Instant,
181+
phase: &str,
182+
output: &TerminalOutput,
183+
diagnostics: &Mutex<Vec<u8>>,
184+
) -> Value {
185+
match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
186+
Ok(Ok(frame)) => frame,
187+
failure => {
188+
let message = format!(
189+
"PTY {phase} failed: {failure:?}; output={:?}; stderr={:?}",
190+
String::from_utf8_lossy(&output.bytes),
191+
String::from_utf8_lossy(&diagnostics.lock().unwrap()),
192+
);
193+
// Bypass libtest capture so timeout diagnostics survive even if
194+
// another Windows cleanup failure aborts the harness.
195+
let _ = writeln!(std::io::stderr().lock(), "{message}");
196+
panic!("{message}");
197+
}
198+
}
199+
}
200+
201+
#[test]
202+
fn terminal_answers_cursor_queries_split_across_output_frames_once() {
203+
let mut terminal = TerminalOutput::default();
204+
assert_eq!(terminal.push(b"\x1b["), 0);
205+
assert_eq!(terminal.push(b"6nfixture-ready"), 1);
206+
assert_eq!(terminal.push(b"\r\n"), 0);
207+
assert_eq!(terminal.push(b"\x1b[6n"), 1);
208+
assert!(terminal.contains("fixture-ready"));
209+
}
210+
122211
#[test]
123212
fn proxy_preserves_project_cwd_binary_stdin_and_child_exit_code() {
124213
let directory = TestDirectory::new("proxy");
@@ -153,37 +242,81 @@ fn pty_starts_in_project_directory_and_supports_resize_input_and_shutdown() {
153242
let directory = TestDirectory::new("pty");
154243
let mut core = spawn_fixture("pty", &directory);
155244
let stdout = core.0.stdout.take().unwrap();
245+
let mut stderr = core.0.stderr.take().unwrap();
246+
let diagnostics = Arc::new(Mutex::new(Vec::new()));
247+
let stderr_capture = Arc::clone(&diagnostics);
248+
let stderr_reader = std::thread::spawn(move || {
249+
let mut buffer = [0_u8; 4096];
250+
while let Ok(count) = stderr.read(&mut buffer) {
251+
if count == 0 {
252+
break;
253+
}
254+
stderr_capture
255+
.lock()
256+
.unwrap()
257+
.extend_from_slice(&buffer[..count]);
258+
}
259+
});
156260
let (sender, receiver) = mpsc::channel();
157261
let reader = std::thread::spawn(move || {
158262
for line in BufReader::new(stdout).lines() {
159-
let frame = serde_json::from_str::<Value>(&line.unwrap()).unwrap();
263+
let frame = line.map_err(|error| error.to_string()).and_then(|line| {
264+
serde_json::from_str::<Value>(&line).map_err(|error| format!("{error}: {line:?}"))
265+
});
160266
if sender.send(frame).is_err() {
161267
break;
162268
}
163269
}
164270
});
165-
let first = receiver
166-
.recv_timeout(TIMEOUT)
167-
.expect("PTY did not become ready");
271+
let mut output = TerminalOutput::default();
272+
let first = receive_frame(
273+
&receiver,
274+
Instant::now() + TIMEOUT,
275+
"host readiness",
276+
&output,
277+
&diagnostics,
278+
);
168279
assert_eq!(first, json!({"protocolVersion": 1, "kind": "ready"}));
169280
let mut stdin = core.0.stdin.take().unwrap();
281+
// Answer terminal queries before resize: ResizePseudoConsole may block
282+
// while ConPTY waits for the cursor reply on its input pipe.
283+
let deadline = Instant::now() + TIMEOUT;
284+
while !output.contains("fixture-ready") {
285+
let frame = receive_frame(
286+
&receiver,
287+
deadline,
288+
"child readiness",
289+
&output,
290+
&diagnostics,
291+
);
292+
assert_eq!(frame["kind"], "output", "unexpected frame: {frame}");
293+
let bytes = STANDARD.decode(frame["data"].as_str().unwrap()).unwrap();
294+
for _ in 0..output.push(&bytes) {
295+
write_request(
296+
&mut stdin,
297+
json!({"protocolVersion": 1, "method": "pty.write", "data": STANDARD.encode(b"\x1b[1;1R")}),
298+
);
299+
}
300+
}
170301
for request in [
171302
json!({"protocolVersion": 1, "method": "pty.resize", "cols": 1000, "rows": 30}),
172303
json!({"protocolVersion": 1, "method": "pty.write", "data": STANDARD.encode(b"native-pty-token\r")}),
173304
] {
174-
writeln!(stdin, "{request}").unwrap();
305+
write_request(&mut stdin, request);
175306
}
176-
let mut output = Vec::new();
177307
let deadline = Instant::now() + TIMEOUT;
178308
loop {
179-
let frame = receiver
180-
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
181-
.expect("PTY did not echo input");
309+
let frame = receive_frame(&receiver, deadline, "input echo", &output, &diagnostics);
182310
assert_eq!(frame["kind"], "output", "unexpected frame: {frame}");
183-
output.extend(STANDARD.decode(frame["data"].as_str().unwrap()).unwrap());
184-
let text = String::from_utf8_lossy(&output);
185-
if text.contains(&expected_cwd(&directory))
186-
&& text.contains("fixture-input=native-pty-token")
311+
let bytes = STANDARD.decode(frame["data"].as_str().unwrap()).unwrap();
312+
for _ in 0..output.push(&bytes) {
313+
write_request(
314+
&mut stdin,
315+
json!({"protocolVersion": 1, "method": "pty.write", "data": STANDARD.encode(b"\x1b[1;1R")}),
316+
);
317+
}
318+
if output.contains(&expected_cwd(&directory))
319+
&& output.contains("fixture-input=native-pty-token")
187320
{
188321
break;
189322
}
@@ -197,5 +330,11 @@ fn pty_starts_in_project_directory_and_supports_resize_input_and_shutdown() {
197330
drop(stdin);
198331
assert!(core.wait().success());
199332
reader.join().unwrap();
200-
assert!(receiver.try_iter().any(|frame| frame["kind"] == "exit"));
333+
stderr_reader.join().unwrap();
334+
assert!(
335+
receiver
336+
.try_iter()
337+
.any(|frame| frame.unwrap()["kind"] == "exit")
338+
);
339+
assert!(diagnostics.lock().unwrap().is_empty());
201340
}

scripts/release/smoke-windows-server.mjs

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,24 @@ import { parseArgs } from 'node:util';
99
import { BUN_VERSION } from '../fetch-bun.mjs';
1010

1111
import { assertOutOfTree } from './out-of-tree.mjs';
12-
import { assertWindowsHost, assertWindowsX64Executable, NODE_VERSION, verifyManifest, windowsSmokeEnvironment } from './windows-payload.mjs';
12+
import { assertWindowsHost, assertWindowsX64Executable, NODE_VERSION, verifyManifest, verifyWindowsSmokeEnvironment, windowsSmokeEnvironment } from './windows-payload.mjs';
1313

14-
export async function runGuardedSmoke({ nodePath, args, cwd, env, jobRuntime, timeoutMs = 120_000, stdout = process.stdout }) {
14+
export async function runGuardedSmoke({ nodePath, args, cwd, env, jobRuntime, timeoutMs = 120_000, stdout = process.stdout, stderr = process.stderr }) {
1515
const { createWindowsJobLaunch, killWindowsJobGuard, GJC_WINDOWS_JOB_GUARD_READY, GJC_WINDOWS_JOB_GUARD_ACK } = jobRuntime;
1616
const launch = createWindowsJobLaunch(nodePath, args, env, cwd);
1717
const child = spawn(launch.command, launch.args, {
18-
cwd, env: launch.env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'inherit'],
18+
cwd, env: launch.env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
1919
});
2020
let timer;
2121
let ready = false;
2222
let buffered = Buffer.alloc(0);
23+
let diagnostics = '';
24+
let failure;
25+
child.stderr.setEncoding('utf8');
26+
child.stderr.on('data', chunk => {
27+
diagnostics = (diagnostics + chunk).slice(-16_384);
28+
stderr.write(chunk);
29+
});
2330
try {
2431
await new Promise((resolve, reject) => {
2532
timer = setTimeout(() => reject(new Error('Windows payload smoke timed out.')), timeoutMs);
@@ -43,34 +50,51 @@ export async function runGuardedSmoke({ nodePath, args, cwd, env, jobRuntime, ti
4350
? resolve()
4451
: reject(new Error(`Windows payload smoke failed (exit ${code}, Job guard ready=${ready}).`)));
4552
});
53+
} catch (error) {
54+
failure = new Error(`${error.message}${diagnostics.trim() ? `\nJob guard diagnostics:\n${diagnostics}` : ''}`);
4655
} finally {
4756
clearTimeout(timer);
4857
// Always reap the named Job, even if its direct child has exited: an early
4958
// checker exit must not leave a detached server, core, or Bun descendant.
50-
await killWindowsJobGuard(child, launch);
59+
try { await killWindowsJobGuard(child, launch); }
60+
catch (error) {
61+
// execFile errors retain the entire encoded guard command. Report the
62+
// cleanup reason and native stderr without dumping that command or losing
63+
// the original startup error underneath it.
64+
const cause = error.cause;
65+
const cleanup = new Error(`${error.message}${cause?.killed ? ' (reaper timed out)' : ''}${cause?.stderr ? `\n${String(cause.stderr).slice(-16_384)}` : ''}`);
66+
failure = failure
67+
? new AggregateError([failure, cleanup], `${failure.message}\nJob cleanup also failed: ${cleanup.message}`)
68+
: cleanup;
69+
}
5170
}
71+
if (failure) throw failure;
5272
}
5373

5474
export async function smokeWindowsServer({ payloadDir, nodePath }) {
5575
assertWindowsHost();
5676
if (!payloadDir || !nodePath) throw new Error('Both payloadDir and nodePath are required.');
5777
const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gajae-windows smoke 가재-'));
78+
let failure;
5879
try {
5980
await assertOutOfTree(temporaryDir, 'Windows server smoke');
6081
const payloadCopy = path.join(temporaryDir, 'server payload 가재');
6182
const runtimeDir = path.join(temporaryDir, 'runtime space 가재');
6283
const stateDir = path.join(temporaryDir, 'user profile 가재');
63-
await fs.cp(path.resolve(payloadDir), payloadCopy, { recursive: true, dereference: false, verbatimSymlinks: true });
6484
await fs.mkdir(runtimeDir, { recursive: true });
85+
await fs.mkdir(payloadCopy, { recursive: true });
86+
const env = windowsSmokeEnvironment(runtimeDir, stateDir);
87+
for (const directory of [stateDir, env.APPDATA, env.LOCALAPPDATA, env.XDG_CONFIG_HOME, env.XDG_DATA_HOME, env.XDG_CACHE_HOME, env.TEMP, env.WORKSPACES_ROOT, env.GJC_WORKER_AGENT_DIR]) {
88+
await fs.mkdir(directory, { recursive: true });
89+
}
90+
const environment = await verifyWindowsSmokeEnvironment(env, payloadCopy);
91+
console.log(`Windows smoke environment verified: ${JSON.stringify(environment)}`);
92+
await fs.cp(path.resolve(payloadDir), payloadCopy, { recursive: true, dereference: false, verbatimSymlinks: true });
6593
const nodeCopy = path.join(runtimeDir, 'gajae-app-server.exe');
6694
await fs.copyFile(path.resolve(nodePath), nodeCopy);
6795
await assertWindowsX64Executable(nodeCopy);
6896
for (const binary of ['bun.exe', 'gajae-core.exe']) await assertWindowsX64Executable(path.join(payloadCopy, 'dist-native', binary));
6997
await verifyManifest(payloadCopy);
70-
const env = windowsSmokeEnvironment(runtimeDir, stateDir);
71-
for (const directory of [stateDir, env.APPDATA, env.LOCALAPPDATA, env.XDG_CONFIG_HOME, env.XDG_DATA_HOME, env.XDG_CACHE_HOME, env.TEMP, env.WORKSPACES_ROOT, env.GJC_WORKER_AGENT_DIR]) {
72-
await fs.mkdir(directory, { recursive: true });
73-
}
7498
const checks = path.join(payloadCopy, '.gajae-windows-smoke.mjs');
7599
await fs.copyFile(fileURLToPath(new URL('./windows-server-smoke-checks.mjs', import.meta.url)), checks);
76100
await fs.copyFile(fileURLToPath(new URL('../../src-tauri/src/windows-server-bootstrap.cjs', import.meta.url)),
@@ -80,9 +104,17 @@ export async function smokeWindowsServer({ payloadDir, nodePath }) {
80104
await runGuardedSmoke({
81105
nodePath: nodeCopy, args: [checks, NODE_VERSION, BUN_VERSION], cwd: payloadCopy, env, jobRuntime,
82106
});
107+
} catch (error) {
108+
failure = error;
83109
} finally {
84-
await fs.rm(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
110+
try { await fs.rm(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); }
111+
catch (error) {
112+
failure = failure
113+
? new AggregateError([failure, error], `${failure.message}\nSmoke directory cleanup also failed: ${error.message}`)
114+
: error;
115+
}
85116
}
117+
if (failure) throw failure;
86118
}
87119

88120
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {

0 commit comments

Comments
 (0)