Skip to content

Commit c91c8aa

Browse files
committed
fix: stabilize PR comment sessions
1 parent 3e5693f commit c91c8aa

4 files changed

Lines changed: 163 additions & 100 deletions

File tree

actions/run-android-comment-session/action.yml

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -324,36 +324,6 @@ runs:
324324
exit 1
325325
fi
326326
327-
cat > simdeck-daemon-supervisor.sh <<'EOF'
328-
#!/usr/bin/env bash
329-
set +e
330-
terminating=0
331-
child=""
332-
trap 'terminating=1; if [[ -n "${child}" ]]; then kill "${child}" 2>/dev/null; wait "${child}" 2>/dev/null; fi; exit 0' TERM INT HUP
333-
334-
while true; do
335-
"$@" >> simdeck-daemon.log 2>&1 &
336-
child="$!"
337-
echo "${child}" > simdeck-child.pid
338-
wait "${child}"
339-
status="$?"
340-
child=""
341-
342-
if [[ "${terminating}" -eq 1 ]]; then
343-
exit 0
344-
fi
345-
if [[ "${status}" -eq 75 || "${status}" -ge 128 ]]; then
346-
echo "[simdeck-action-supervisor] daemon exited with status ${status}; restarting" >> simdeck-daemon.log
347-
sleep 1
348-
continue
349-
fi
350-
351-
echo "[simdeck-action-supervisor] daemon exited with status ${status}; not restarting" >> simdeck-daemon.log
352-
exit "${status}"
353-
done
354-
EOF
355-
chmod +x simdeck-daemon-supervisor.sh
356-
357327
SIMDECK_VIDEO_CODEC=software \
358328
SIMDECK_ANDROID_VIDEO_CODEC=software \
359329
SIMDECK_ALLOWED_ORIGINS='*' \
@@ -364,7 +334,6 @@ runs:
364334
SIMDECK_REALTIME_MIN_BITRATE="${stream_min_bitrate}" \
365335
SIMDECK_REALTIME_BITS_PER_PIXEL="${stream_bits_per_pixel}" \
366336
SIMDECK_LOCAL_STREAM_FPS="${stream_fps}" \
367-
./simdeck-daemon-supervisor.sh \
368337
simdeck daemon run \
369338
--project-root "${GITHUB_WORKSPACE}" \
370339
--metadata-path "${metadata_path}" \
@@ -374,7 +343,8 @@ runs:
374343
--stream-quality "${SIMDECK_STREAM_PROFILE}" \
375344
--local-stream-fps "${stream_fps}" \
376345
--access-token "${access_token}" \
377-
--pairing-code 000000 &
346+
--pairing-code 000000 \
347+
> simdeck-daemon.log 2>&1 &
378348
echo "$!" > simdeck.pid
379349
380350
cloudflared tunnel --url "http://127.0.0.1:${SIMDECK_PORT}" --protocol http2 --no-autoupdate > cloudflared.log 2>&1 &

actions/run-ios-comment-session/action.yml

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -289,36 +289,6 @@ runs:
289289
exit 1
290290
fi
291291
292-
cat > simdeck-daemon-supervisor.sh <<'EOF'
293-
#!/usr/bin/env bash
294-
set +e
295-
terminating=0
296-
child=""
297-
trap 'terminating=1; if [[ -n "${child}" ]]; then kill "${child}" 2>/dev/null; wait "${child}" 2>/dev/null; fi; exit 0' TERM INT HUP
298-
299-
while true; do
300-
"$@" >> simdeck-daemon.log 2>&1 &
301-
child="$!"
302-
echo "${child}" > simdeck-child.pid
303-
wait "${child}"
304-
status="$?"
305-
child=""
306-
307-
if [[ "${terminating}" -eq 1 ]]; then
308-
exit 0
309-
fi
310-
if [[ "${status}" -eq 75 || "${status}" -ge 128 ]]; then
311-
echo "[simdeck-action-supervisor] daemon exited with status ${status}; restarting" >> simdeck-daemon.log
312-
sleep 1
313-
continue
314-
fi
315-
316-
echo "[simdeck-action-supervisor] daemon exited with status ${status}; not restarting" >> simdeck-daemon.log
317-
exit "${status}"
318-
done
319-
EOF
320-
chmod +x simdeck-daemon-supervisor.sh
321-
322292
SIMDECK_VIDEO_CODEC=software \
323293
SIMDECK_ALLOWED_ORIGINS='*' \
324294
SIMDECK_REALTIME_STREAM=1 \
@@ -328,7 +298,6 @@ runs:
328298
SIMDECK_REALTIME_MIN_BITRATE="${stream_min_bitrate}" \
329299
SIMDECK_REALTIME_BITS_PER_PIXEL="${stream_bits_per_pixel}" \
330300
SIMDECK_LOCAL_STREAM_FPS="${stream_fps}" \
331-
./simdeck-daemon-supervisor.sh \
332301
simdeck daemon run \
333302
--project-root "${GITHUB_WORKSPACE}" \
334303
--metadata-path "${metadata_path}" \
@@ -338,7 +307,8 @@ runs:
338307
--stream-quality "${SIMDECK_STREAM_PROFILE}" \
339308
--local-stream-fps "${stream_fps}" \
340309
--access-token "${access_token}" \
341-
--pairing-code 000000 &
310+
--pairing-code 000000 \
311+
> simdeck-daemon.log 2>&1 &
342312
echo "$!" > simdeck.pid
343313
344314
cloudflared tunnel --url "http://127.0.0.1:${SIMDECK_PORT}" --protocol http2 --no-autoupdate > cloudflared.log 2>&1 &

bin/simdeck.mjs

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import { existsSync } from "node:fs";
55
import path from "node:path";
66
import { fileURLToPath } from "node:url";
77

8+
const RECOVERABLE_RESTART_EXIT_CODE = 75;
9+
810
const packageRoot = path.resolve(
911
path.dirname(fileURLToPath(import.meta.url)),
1012
"..",
1113
);
1214
const binaryPath = path.join(packageRoot, "build", "simdeck-bin");
15+
const childArgs = process.argv.slice(2);
16+
const isDaemonRun = childArgs[0] === "daemon" && childArgs[1] === "run";
1317

1418
if (process.platform !== "darwin") {
1519
console.error("simdeck only supports macOS.");
@@ -23,28 +27,53 @@ if (!existsSync(binaryPath)) {
2327
process.exit(1);
2428
}
2529

26-
const child = spawn(binaryPath, process.argv.slice(2), {
27-
cwd: process.cwd(),
28-
stdio: "inherit",
29-
});
30-
31-
child.on("error", (error) => {
32-
console.error(error.message);
33-
process.exit(1);
34-
});
30+
let child;
31+
let terminating = false;
3532

3633
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
3734
process.once(signal, () => {
38-
if (!child.killed) {
35+
terminating = true;
36+
if (child && !child.killed) {
3937
child.kill(signal);
4038
}
4139
});
4240
}
4341

44-
child.on("exit", (code, signal) => {
45-
if (signal) {
46-
process.kill(process.pid, signal);
47-
return;
48-
}
49-
process.exit(code ?? 1);
50-
});
42+
function spawnChild() {
43+
const env = isDaemonRun
44+
? {
45+
...process.env,
46+
SIMDECK_DAEMON_METADATA_PID: String(process.pid),
47+
}
48+
: process.env;
49+
50+
child = spawn(binaryPath, childArgs, {
51+
cwd: process.cwd(),
52+
env,
53+
stdio: "inherit",
54+
});
55+
56+
child.on("error", (error) => {
57+
console.error(error.message);
58+
process.exit(1);
59+
});
60+
61+
child.on("exit", (code, signal) => {
62+
if (
63+
isDaemonRun &&
64+
!terminating &&
65+
(code === RECOVERABLE_RESTART_EXIT_CODE || signal)
66+
) {
67+
setTimeout(spawnChild, 500);
68+
return;
69+
}
70+
71+
if (signal) {
72+
process.kill(process.pid, signal);
73+
return;
74+
}
75+
process.exit(code ?? 1);
76+
});
77+
}
78+
79+
spawnChild();

scripts/github-actions.test.mjs

Lines changed: 113 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import assert from "node:assert/strict";
2-
import { readFileSync } from "node:fs";
2+
import {
3+
chmodSync,
4+
copyFileSync,
5+
mkdirSync,
6+
mkdtempSync,
7+
readFileSync,
8+
rmSync,
9+
writeFileSync,
10+
} from "node:fs";
11+
import { tmpdir } from "node:os";
12+
import { join } from "node:path";
13+
import { spawnSync } from "node:child_process";
314
import { test } from "node:test";
415

516
const iosAction = readFileSync(
@@ -25,6 +36,8 @@ function stepSlice(action, name, nextName) {
2536
return action.slice(startIndex, endIndex);
2637
}
2738

39+
const darwinTest = process.platform === "darwin" ? test : test.skip;
40+
2841
test("iOS PR comment waits for public simulator list access", () => {
2942
const prebootIndex = iosAction.indexOf(
3043
"- name: Select and preboot simulator",
@@ -149,33 +162,20 @@ for (const [platform, action, startStep, waitStep] of [
149162
);
150163
});
151164

152-
test(`${platform} PR comment supervises recoverable daemon exits`, () => {
165+
test(`${platform} PR comment relies on the packaged CLI daemon supervisor`, () => {
153166
const startStepBody = stepSlice(
154167
action,
155168
"Install tools, start SimDeck and tunnel",
156169
"Resolve PR head",
157170
);
158171

159-
assert.match(
172+
assert.doesNotMatch(
160173
startStepBody,
161174
/simdeck-daemon-supervisor\.sh/,
162-
"action should run SimDeck through a local supervisor",
163-
);
164-
assert.match(
165-
startStepBody,
166-
/"\$\{status\}" -eq 75/,
167-
"supervisor should restart recoverable SimDeck exits",
168-
);
169-
assert.match(
170-
startStepBody,
171-
/"\$\{status\}" -ge 128/,
172-
"supervisor should restart signal-terminated daemon children",
173-
);
174-
assert.match(
175-
startStepBody,
176-
/simdeck-child\.pid/,
177-
"supervisor should expose the active child pid for cleanup diagnostics",
175+
"action should not carry a second workflow-local daemon supervisor",
178176
);
177+
assert.match(startStepBody, /simdeck daemon run/);
178+
assert.match(startStepBody, /echo "\$!" > simdeck\.pid/);
179179
});
180180

181181
test(`${platform} PR comment keepalive tolerates transient daemon restarts`, () => {
@@ -207,3 +207,97 @@ for (const [platform, action, startStep, waitStep] of [
207207
);
208208
});
209209
}
210+
211+
darwinTest(
212+
"npm CLI wrapper restarts daemon run after recoverable native exit",
213+
() => {
214+
const root = mkdtempSync(join(tmpdir(), "simdeck-wrapper-test-"));
215+
try {
216+
mkdirSync(join(root, "bin"), { recursive: true });
217+
mkdirSync(join(root, "build"), { recursive: true });
218+
const wrapperPath = join(root, "bin", "simdeck.mjs");
219+
const nativePath = join(root, "build", "simdeck-bin");
220+
const logPath = join(root, "native.log");
221+
const countPath = join(root, "count");
222+
223+
copyFileSync(new URL("../bin/simdeck.mjs", import.meta.url), wrapperPath);
224+
chmodSync(wrapperPath, 0o755);
225+
writeFileSync(
226+
nativePath,
227+
`#!/usr/bin/env bash
228+
set -euo pipefail
229+
count="$(cat "${countPath}" 2>/dev/null || echo 0)"
230+
count="$((count + 1))"
231+
echo "$count" > "${countPath}"
232+
echo "$$:\${SIMDECK_DAEMON_METADATA_PID:-}:\$*" >> "${logPath}"
233+
if [[ "$count" == "1" ]]; then
234+
exit 75
235+
fi
236+
exit 0
237+
`,
238+
);
239+
chmodSync(nativePath, 0o755);
240+
241+
const result = spawnSync(
242+
process.execPath,
243+
[wrapperPath, "daemon", "run", "--port", "4310"],
244+
{
245+
encoding: "utf8",
246+
},
247+
);
248+
249+
assert.equal(result.status, 0, result.stderr);
250+
const logLines = readFileSync(logPath, "utf8").trim().split("\n");
251+
assert.equal(logLines.length, 2, "daemon run should be retried once");
252+
253+
const entries = logLines.map((line) => {
254+
const [pid, metadataPid, args] = line.split(":");
255+
return { pid, metadataPid, args };
256+
});
257+
assert.notEqual(entries[0].pid, entries[1].pid);
258+
assert.match(entries[0].metadataPid, /^\d+$/);
259+
assert.equal(entries[0].metadataPid, entries[1].metadataPid);
260+
assert.notEqual(entries[0].pid, entries[0].metadataPid);
261+
assert.equal(entries[0].args, "daemon run --port 4310");
262+
} finally {
263+
rmSync(root, { recursive: true, force: true });
264+
}
265+
},
266+
);
267+
268+
darwinTest(
269+
"npm CLI wrapper does not restart non-daemon commands on exit 75",
270+
() => {
271+
const root = mkdtempSync(join(tmpdir(), "simdeck-wrapper-test-"));
272+
try {
273+
mkdirSync(join(root, "bin"), { recursive: true });
274+
mkdirSync(join(root, "build"), { recursive: true });
275+
const wrapperPath = join(root, "bin", "simdeck.mjs");
276+
const nativePath = join(root, "build", "simdeck-bin");
277+
const logPath = join(root, "native.log");
278+
279+
copyFileSync(new URL("../bin/simdeck.mjs", import.meta.url), wrapperPath);
280+
chmodSync(wrapperPath, 0o755);
281+
writeFileSync(
282+
nativePath,
283+
`#!/usr/bin/env bash
284+
set -euo pipefail
285+
echo "$$:\${SIMDECK_DAEMON_METADATA_PID:-}:\$*" >> "${logPath}"
286+
exit 75
287+
`,
288+
);
289+
chmodSync(nativePath, 0o755);
290+
291+
const result = spawnSync(process.execPath, [wrapperPath, "list"], {
292+
encoding: "utf8",
293+
});
294+
295+
assert.equal(result.status, 75);
296+
const logLines = readFileSync(logPath, "utf8").trim().split("\n");
297+
assert.equal(logLines.length, 1);
298+
assert.equal(logLines[0].split(":")[1], "");
299+
} finally {
300+
rmSync(root, { recursive: true, force: true });
301+
}
302+
},
303+
);

0 commit comments

Comments
 (0)