Skip to content

Commit 74e72b9

Browse files
authored
Merge pull request #726 from nexu-io/merge/release-v0.1.8-to-main
chore: merge release/v0.1.8 back to main
2 parents 479a2c7 + 3b890b0 commit 74e72b9

11 files changed

Lines changed: 634 additions & 73 deletions

File tree

.github/workflows/desktop-e2e.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,86 @@ jobs:
140140
e2e/desktop/captures/
141141
if-no-files-found: warn
142142
retention-days: 14
143+
144+
- name: Notify Feishu E2E result
145+
if: always()
146+
env:
147+
FEISHU_WEBHOOK: ${{ secrets.NIGHTLY_FEISHU_WEBHOOK }}
148+
E2E_MODE: ${{ github.event.inputs.mode || 'model' }}
149+
E2E_SOURCE: ${{ github.event.inputs.source || 'download' }}
150+
E2E_CHANNEL: ${{ github.event.inputs.channel || 'nightly' }}
151+
E2E_STATUS: ${{ job.status }}
152+
shell: bash
153+
run: |
154+
set -euo pipefail
155+
if [ -z "$FEISHU_WEBHOOK" ]; then
156+
echo "No Feishu webhook, skipping"
157+
exit 0
158+
fi
159+
160+
run_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
161+
short_sha="${GITHUB_SHA::7}"
162+
branch="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
163+
trigger="${GITHUB_TRIGGERING_ACTOR:-unknown}"
164+
165+
if [ "$E2E_STATUS" = "success" ]; then
166+
template="green"
167+
title="✅ Desktop E2E 测试通过"
168+
else
169+
template="red"
170+
title="❌ Desktop E2E 测试失败"
171+
fi
172+
173+
# Determine what triggered this: PR, commit, or scheduled
174+
trigger_info=""
175+
if [ -n "$GITHUB_HEAD_REF" ]; then
176+
pr_number=$(echo "$GITHUB_REF" | grep -oE '[0-9]+' || echo "")
177+
trigger_info="PR [#${pr_number}](https://github.com/${{ github.repository }}/pull/${pr_number}) on \`${branch}\`"
178+
elif [ "${{ github.event_name }}" = "schedule" ]; then
179+
trigger_info="定时触发 (\`${branch}\` @ \`${short_sha}\`)"
180+
else
181+
trigger_info="手动触发 (\`${branch}\` @ \`${short_sha}\`)"
182+
fi
183+
184+
card=$(jq -n \
185+
--arg title "$title" \
186+
--arg template "$template" \
187+
--arg mode "$E2E_MODE" \
188+
--arg source "$E2E_SOURCE" \
189+
--arg channel "$E2E_CHANNEL" \
190+
--arg trigger_info "$trigger_info" \
191+
--arg trigger "$trigger" \
192+
--arg run_url "$run_url" \
193+
'{
194+
msg_type: "interactive",
195+
card: {
196+
header: {
197+
template: $template,
198+
title: {
199+
tag: "plain_text",
200+
content: $title
201+
}
202+
},
203+
elements: [
204+
{
205+
tag: "markdown",
206+
content: ("**触发来源**\n" + $trigger_info + "\n触发人: " + $trigger + "\n\n**测试配置**\n- 模式: `" + $mode + "`\n- 构建来源: `" + $source + "`\n- 频道: `" + $channel + "`")
207+
},
208+
{
209+
tag: "action",
210+
actions: [
211+
{
212+
tag: "button",
213+
text: { tag: "plain_text", content: "📋 查看详情" },
214+
type: "default",
215+
url: $run_url
216+
}
217+
]
218+
}
219+
]
220+
}
221+
}')
222+
223+
curl -sf -X POST "$FEISHU_WEBHOOK" \
224+
-H "Content-Type: application/json" \
225+
-d "$card" || echo "Feishu notification failed (non-fatal)"

apps/desktop/main/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ async function runLaunchdColdStart(): Promise<void> {
683683
skillNodePath,
684684
openclawTmpDir,
685685
proxyEnv,
686+
log: (message: string) => logColdStart(message),
686687
appVersion: app.getVersion(),
687688
userDataPath: app.getPath("userData"),
688689
buildSource:

apps/desktop/main/services/launchd-bootstrap.ts

Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import { execFile } from "node:child_process";
1212
import { existsSync, readFileSync, writeFileSync } from "node:fs";
1313
import * as fs from "node:fs/promises";
14-
import { createConnection } from "node:net";
14+
import net, { createConnection } from "node:net";
1515
import * as os from "node:os";
1616
import * as path from "node:path";
1717
import { promisify } from "node:util";
@@ -83,6 +83,8 @@ export interface LaunchdBootstrapEnv {
8383
openclawTmpDir: string;
8484
/** Normalized proxy env propagated to controller/openclaw launchd services */
8585
proxyEnv: Record<string, string>;
86+
/** Optional structured logger for packaged mode (console.log is lost in packaged builds) */
87+
log?: (message: string) => void;
8688
}
8789

8890
export interface LaunchdBootstrapResult {
@@ -278,20 +280,28 @@ function isProcessAlive(pid: number): boolean {
278280
// Port occupier detection
279281
// ---------------------------------------------------------------------------
280282

283+
/**
284+
* Check if a port is occupied by attempting to bind a temporary server.
285+
* Returns `{ pid: 0 }` if occupied, `null` if free.
286+
*
287+
* Uses net.createServer().listen() instead of lsof or net.connect because:
288+
* - lsof is blocked by macOS hardened runtime in packaged Electron apps
289+
* - net.connect conflicts with probePort (both use createConnection)
290+
*/
281291
async function detectPortOccupier(
282292
port: number,
283293
): Promise<{ pid: number } | null> {
284-
try {
285-
const { stdout } = await execFileAsync("lsof", [
286-
`-iTCP:${port}`,
287-
"-sTCP:LISTEN",
288-
"-t",
289-
]);
290-
const pid = Number.parseInt(stdout.trim(), 10);
291-
return Number.isNaN(pid) ? null : { pid };
292-
} catch {
293-
return null;
294-
}
294+
return new Promise((resolve) => {
295+
const server = net.createServer();
296+
server.once("error", () => {
297+
// EADDRINUSE or other bind failure — port is occupied
298+
resolve({ pid: 0 });
299+
});
300+
server.listen(port, "127.0.0.1", () => {
301+
// Successfully bound — port is free. Close immediately.
302+
server.close(() => resolve(null));
303+
});
304+
});
295305
}
296306

297307
/**
@@ -374,6 +384,7 @@ async function cleanupStalePlists(
374384
export async function bootstrapWithLaunchd(
375385
env: LaunchdBootstrapEnv,
376386
): Promise<LaunchdBootstrapResult> {
387+
const log = env.log ?? console.log;
377388
const logDir = await ensureLogDir(env.nexuHome);
378389
const plistDir = env.plistDir ?? getDefaultPlistDir(env.isDev);
379390

@@ -617,7 +628,23 @@ export async function bootstrapWithLaunchd(
617628
}
618629

619630
if (openclawRunning && useRecoveredPorts) {
620-
openclawHealthy = await probePort(effectivePorts.openclawPort);
631+
const portListening = await probePort(effectivePorts.openclawPort);
632+
// Port listening isn't enough — verify it's OUR openclaw by checking
633+
// that the launchd service env matches our expected token/state dir.
634+
// This prevents attaching to a global openclaw or ClawX on the same port.
635+
if (portListening) {
636+
const ocEnv = (await launchd.getServiceStatus(labels.openclaw)).env;
637+
const expectedToken = env.gatewayToken;
638+
const runningToken = ocEnv?.OPENCLAW_GATEWAY_TOKEN;
639+
if (expectedToken && runningToken && runningToken !== expectedToken) {
640+
console.log(
641+
"OpenClaw port is listening but gateway token mismatch — not our instance",
642+
);
643+
openclawHealthy = false;
644+
} else {
645+
openclawHealthy = true;
646+
}
647+
}
621648
if (openclawHealthy) {
622649
console.log("OpenClaw already running and healthy");
623650
} else {
@@ -644,12 +671,20 @@ export async function bootstrapWithLaunchd(
644671
}
645672
}
646673
if (!openclawHealthy) {
674+
const preOccupier = await detectPortOccupier(effectivePorts.openclawPort);
675+
log(
676+
`[bootstrap] pre-findFreePort: openclawPort=${effectivePorts.openclawPort} occupier=${preOccupier ? `PID ${preOccupier.pid}` : "none"}`,
677+
);
647678
const freePort = await findFreePort(effectivePorts.openclawPort);
648679
if (freePort !== effectivePorts.openclawPort) {
649680
console.log(
650681
`OpenClaw port ${effectivePorts.openclawPort} occupied, using ${freePort}`,
651682
);
652683
effectivePorts.openclawPort = freePort;
684+
} else {
685+
log(
686+
`[bootstrap] openclawPort ${effectivePorts.openclawPort} appears free, keeping`,
687+
);
653688
}
654689
}
655690

@@ -697,6 +732,66 @@ export async function bootstrapWithLaunchd(
697732
if (!openclawHealthy) {
698733
await ensureService(labels.openclaw, "openclaw");
699734
await ensureRunning(labels.openclaw, "openclaw");
735+
736+
// Verify our openclaw actually owns the port. Another launchd service
737+
// (e.g. global `ai.openclaw.gateway` with KeepAlive=true) may have
738+
// raced us and grabbed the port first. If so, pick a new port and
739+
// re-bootstrap our service.
740+
// Wait briefly for the port to be bound (our openclaw needs time to start).
741+
await new Promise((r) => setTimeout(r, 2000));
742+
const occupier = await detectPortOccupier(effectivePorts.openclawPort);
743+
const ocStatus = await launchd.getServiceStatus(labels.openclaw);
744+
log(
745+
`[bootstrap] post-launch check: port=${effectivePorts.openclawPort} occupied=${!!occupier} ocStatus=${JSON.stringify({ pid: ocStatus.pid, status: ocStatus.status })}`,
746+
);
747+
// Port is stolen if someone is listening but our service crashed or
748+
// isn't running. We can't compare PIDs (lsof blocked by hardened
749+
// runtime), so check if our service is healthy instead.
750+
const portStolen =
751+
occupier && (ocStatus.pid == null || ocStatus.status !== "running");
752+
log(`[bootstrap] portStolen=${portStolen}`);
753+
if (portStolen) {
754+
log(
755+
`[bootstrap] OpenClaw port ${effectivePorts.openclawPort} stolen by PID ${occupier.pid} (ours is ${ocStatus.pid}), reassigning`,
756+
);
757+
// Bootout crashed openclaw and wait for launchd to fully release it.
758+
// Use bootoutAndWaitForExit which captures the PID before bootout
759+
// so waitForExit can SIGKILL if needed (plain waitForExit without
760+
// knownPid exits early on "unknown" status).
761+
await launchd
762+
.bootoutAndWaitForExit(labels.openclaw, 5000)
763+
.catch(() => {});
764+
765+
const newPort = await findFreePort(effectivePorts.openclawPort + 1);
766+
effectivePorts.openclawPort = newPort;
767+
768+
// Regenerate plists with new port for both openclaw and controller
769+
const retryPlistEnv: PlistEnv = {
770+
...plistEnv,
771+
openclawPort: newPort,
772+
};
773+
774+
// Re-bootstrap openclaw on new port
775+
const retryPlist = generatePlist("openclaw", retryPlistEnv);
776+
await launchd.installService(labels.openclaw, retryPlist);
777+
await launchd.startService(labels.openclaw);
778+
await ensureRunning(labels.openclaw, "openclaw");
779+
780+
// Controller needs the new port — re-bootstrap it too
781+
await launchd
782+
.bootoutAndWaitForExit(labels.controller, 5000)
783+
.catch(() => {});
784+
const retryControllerPlist = generatePlist("controller", retryPlistEnv);
785+
await launchd.installService(labels.controller, retryControllerPlist);
786+
await launchd.startService(labels.controller);
787+
await ensureRunning(labels.controller, "controller");
788+
// Controller was restarted — must wait for readiness again even if
789+
// it was previously healthy (attach path sets needsControllerReady=false).
790+
needsControllerReady = true;
791+
log(
792+
`[bootstrap] OpenClaw reassigned to port ${newPort}, controller restarted`,
793+
);
794+
}
700795
} else {
701796
console.log("[bootstrap] openclaw already healthy, skipping");
702797
}

apps/desktop/main/services/plist-generator.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,9 @@ function generateOpenclawPlist(label: string, env: PlistEnv): string {
229229
<string>${escapeXml(env.nodePath)}</string>
230230
<string>${escapeXml(env.openclawPath)}</string>
231231
<string>gateway</string>
232-
<string>run</string>${authArgs}
232+
<string>run</string>
233+
<string>--port</string>
234+
<string>${env.openclawPort}</string>${authArgs}
233235
</array>
234236
235237
<key>WorkingDirectory</key>
@@ -250,7 +252,13 @@ function generateOpenclawPlist(label: string, env: PlistEnv): string {
250252
<key>OPENCLAW_SERVICE_MARKER</key>
251253
<string>launchd</string>
252254
<key>OPENCLAW_IMAGE_BACKEND</key>
253-
<string>sips</string>
255+
<string>sips</string>${
256+
env.gatewayToken
257+
? `
258+
<key>OPENCLAW_GATEWAY_TOKEN</key>
259+
<string>${escapeXml(env.gatewayToken)}</string>`
260+
: ""
261+
}
254262
<key>HOME</key>
255263
<string>${escapeXml(os.homedir())}</string>${renderProxyEnvEntries(
256264
env.proxyEnv,

e2e/desktop/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ NEXU_DESKTOP_E2E_SKIP_CODESIGN=true npm run test:model
129129
|----------|------------------|-------------------|
130130
| **Crash Recovery** | `kill -9` the Electron main process (Force Quit simulation) | launchd services survive; app attaches or rebuilds on restart |
131131
| **Orphan Cleanup** | Kill Electron, leave controller/openclaw as orphan processes | App detects and cleans up orphans on restart, starts fresh |
132-
| **Port Conflict** | Occupy port 50800 with a dummy listener before launching | App detects EADDRINUSE, picks an alternative port or exits gracefully |
132+
| **Port Conflict (Controller)** | Occupy port 50800 with a dummy listener before launching | App detects EADDRINUSE, picks an alternative port or exits gracefully |
133+
| **Port Conflict (OpenClaw)** | Occupy port 18789 (simulating global `openclaw install` or ClawX) | App detects conflict, auto-assigns alternative port (18790+), coexists without killing the other service |
134+
| **Port Theft (OpenClaw)** | Start normally, kill openclaw, occupy its port, re-launch | App detects port was stolen on cold start, reassigns to new port, recovers |
133135
| **Stale State** | Write a fake `runtime-ports.json` pointing to non-existent services | App detects stale session, ignores fake state, performs fresh start |
134136
| **Double Launch** | Start a second instance while the app is already running | Second instance exits (single-instance lock), first instance unaffected |
135137

0 commit comments

Comments
 (0)