From 0a80c384300a079c9b44179b5b1b6b165a21ffdd Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 5 Nov 2026 18:05:57 +1100 Subject: [PATCH 1/4] Quit the installed macOS tray as macOS does, a logout's quit among them The tray-install job started the tray and waited for it to listen, and nothing ever asked it to quit. On macOS it now sends the tray the quit Apple events by pid: an ordinary one, which the tray refuses (-128) and turns into its own exit, and a logout's, kAEQuitReason set to kAELogOut, which it has to let through. Each has to end the tray with status 0 and its version marker gone. CI cannot log out; the event is what a logout sends, so the whole of TerminateGuard's session path runs. --- .github/scripts/send-quit.swift | 36 +++++++++++++++++ .github/workflows/build.yml | 68 ++++++++++++++++++++++++++++++++- claude.md | 7 +++- 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/send-quit.swift diff --git a/.github/scripts/send-quit.swift b/.github/scripts/send-quit.swift new file mode 100644 index 00000000..3e9ceac8 --- /dev/null +++ b/.github/scripts/send-quit.swift @@ -0,0 +1,36 @@ +// Sends a process the quit Apple event macOS sends it, and prints the error number its reply +// carries, 0 for none. Used by the tray-install job on macOS, which has no logout to give the +// tray: `send-quit ` is a quit from the Dock or AppleScript, and `send-quit logout` is +// the one a logout sends, the quit with kAEQuitReason set to kAELogOut. +// +// A quit the app refuses is answered userCanceledErr (-128), which is how a logout learns it was +// refused, and cancelled. A quit the app lets through ends it before it replies, so the send +// fails on a connection that has gone instead, which is printed as its error number too. + +import Foundation + +let arguments = CommandLine.arguments +guard arguments.count >= 2, let pid = Int32(arguments[1]) else { + FileHandle.standardError.write("usage: send-quit [logout]\n".data(using: .utf8)!) + exit(2) +} + +let event = NSAppleEventDescriptor( + eventClass: AEEventClass(kCoreEventClass), + eventID: AEEventID(kAEQuitApplication), + targetDescriptor: NSAppleEventDescriptor(processIdentifier: pid), + returnID: AEReturnID(kAutoGenerateReturnID), + transactionID: AETransactionID(kAnyTransactionID)) + +if arguments.count >= 3, arguments[2] == "logout" { + event.setParam( + NSAppleEventDescriptor(enumCode: OSType(kAELogOut)), + forKeyword: AEKeyword(kAEQuitReason)) +} + +do { + let reply = try event.sendEvent(options: [.waitForReply], timeout: 10) + print(reply.paramDescriptor(forKeyword: AEKeyword(keyErrorNumber))?.int32Value ?? 0) +} catch let error as NSError { + print(error.code) +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f86f59dc..2c9e18f6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -378,7 +378,8 @@ jobs: # The packaging tests read them; only `dotnet tool install` choosing between them shows the # right one is found for a machine, and only starting what it laid out shows that it runs. So # this installs the tray from the windows job's packages, on each platform, as someone would - # from nuget.org, and waits for the installed tray to listen on the piper port. + # from nuget.org, and waits for the installed tray to listen on the piper port. On macOS it then + # quits the tray both ways macOS does, a logout's quit among them. # # From a source of those packages alone. Until a release, their version is the one in # Directory.Build.props, which nuget.org already has as an older tray, and an install that could @@ -437,6 +438,71 @@ jobs: cat "$tool/logs/log.txt" || true kill "$pid" || true [ -n "$listening" ] || { echo "::error::The installed tray did not listen on 3492"; exit 1; } + wait "$pid" || true + echo "tray=$tray" >> "$GITHUB_ENV" + echo "tool=$tool" >> "$GITHUB_ENV" + + # The quits macOS sends, by Apple event as it sends them: an ordinary one, which the tray + # refuses and turns into its own exit, and a logout's, which it has to let through or the + # logout is cancelled. CI cannot log out, so the logout's is sent by hand, with the reason a + # logout gives it. Either way the tray exits and takes its version marker with it; after a + # logout's, only the session end it ran can have done that, since AppKit ends the process + # without the loop's own way out. + - name: Quit the tray as macOS does + if: runner.os == 'macOS' + shell: bash + run: | + xcrun swiftc -O -o "$RUNNER_TEMP/send-quit" .github/scripts/send-quit.swift + marker="${TMPDIR%/}/DiffEngineTray/version.txt" + + quit() { + "$tray/DiffEngineTray" & + pid=$! + for i in $(seq 1 30); do + (exec 3<>/dev/tcp/127.0.0.1/3492) 2>/dev/null && break + sleep 1 + done + if grep -q "No status item" "$tool/logs/log.txt"; then + cat "$tool/logs/log.txt" + echo "::error::The tray has no status item, so nothing pumps the Apple events sent to it" + exit 1 + fi + test -f "$marker" || { echo "::error::The tray did not write $marker"; exit 1; } + + code=$("$RUNNER_TEMP/send-quit" "$pid" $1) + echo "A ${1:-plain} quit was answered $code" + + exited= + for i in $(seq 1 20); do + state=$(ps -o stat= -p "$pid" | tr -d ' ' || true) + if [ -z "$state" ] || [[ "$state" == Z* ]]; then + exited=1 + break + fi + sleep 0.5 + done + if [ -z "$exited" ]; then + kill "$pid" || true + cat "$tool/logs/log.txt" + echo "::error::The tray was still running ten seconds after a ${1:-plain} quit" + exit 1 + fi + wait "$pid" && status=0 || status=$? + [ "$status" = 0 ] || { cat "$tool/logs/log.txt"; echo "::error::The tray exited $status after a ${1:-plain} quit"; exit 1; } + ! test -f "$marker" || { echo "::error::The tray left $marker behind after a ${1:-plain} quit"; exit 1; } + } + + quit + [ "$code" = -128 ] || { echo "::error::A plain quit was answered $code, where the tray refuses it (-128) and exits by its own loop"; exit 1; } + + quit logout + [ "$code" != -128 ] || { echo "::error::A logout's quit was refused (-128), which cancels the logout"; exit 1; } + + cat "$tool/logs/log.txt" + if grep -q "Failed while the session was ending" "$tool/logs/log.txt"; then + echo "::error::The session end failed" + exit 1 + fi # On Windows the command has to be a GUI apphost as well: a Run key naming a console one, or # a .cmd, opens a console at every login. diff --git a/claude.md b/claude.md index 3cf0bec2..522e4d7c 100644 --- a/claude.md +++ b/claude.md @@ -795,7 +795,12 @@ the comment there about not caching "nothing staged" asks for. - CI's `tray-install` job installs the tray from the `windows` job's packages on Windows, Linux and macOS, from a source of those alone, checks the viewer and documents folder are where the lookups expect and (on Windows) that the command is a GUI apphost, then starts it and waits for - it to listen on 3492. `publish` waits for it + it to listen on 3492. On macOS it then sends the tray the quit Apple events macOS sends + (`.github/scripts/send-quit.swift`, by pid): an ordinary one, which has to be answered -128 and + end the tray by its own loop, and a logout's, `kAEQuitReason` set to `kAELogOut`, which must + not be answered -128, the refusal that cancels a logout. Both have to end the tray with status + 0 and its version marker gone. CI cannot log out, so this is the nearest to one it has. + `publish` waits for it - Update is PlatformHeads' `ToolUpdate` on every head, through the core's `TrayUpdate`, which finds the shim from where the tray runs (`ToolUpdate.ShimFor`, null for a build's output) and keeps the outcome beside the settings, out of the store the update replaces. A shell waits for From 2d5d0108fcc36bc6102176528d0efa5c15fe29eb Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 5 Nov 2026 18:23:52 +1100 Subject: [PATCH 2/4] Wait for the tray's own process to listen, not for a connection On macOS a connection to 3492 was taken before the installed tray had started, so the install step never waited for it, and the quit events went to a tray that was still starting. Ask lsof whether the tray's own process id is listening, and list whatever else holds the port first. --- .github/workflows/build.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c9e18f6..09458209 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -425,11 +425,15 @@ jobs: test -x "$tool/viewer/$rid/DiffEngineViewer" || { echo "::error::No viewer for $rid at $tool/viewer"; exit 1; } test -f "$tool/viewer/documents/DiffEngineViewer.Documents.dll" || { echo "::error::No documents folder at $tool/viewer"; exit 1; } + # By the tray's own process id: on macOS a connection to 3492 was taken before the tray had + # so much as started, so connecting said nothing about it + echo "Listening on 3492 before the tray starts:" + lsof -nP -iTCP:3492 -sTCP:LISTEN || true "$tray/DiffEngineTray" & pid=$! listening= for i in $(seq 1 30); do - if (exec 3<>/dev/tcp/127.0.0.1/3492) 2>/dev/null; then + if lsof -nP -a -p "$pid" -iTCP:3492 -sTCP:LISTEN > /dev/null 2>&1; then listening=1 break fi @@ -458,10 +462,15 @@ jobs: quit() { "$tray/DiffEngineTray" & pid=$! + listening= for i in $(seq 1 30); do - (exec 3<>/dev/tcp/127.0.0.1/3492) 2>/dev/null && break + if lsof -nP -a -p "$pid" -iTCP:3492 -sTCP:LISTEN > /dev/null 2>&1; then + listening=1 + break + fi sleep 1 done + [ -n "$listening" ] || { cat "$tool/logs/log.txt" || true; echo "::error::The tray did not listen on 3492"; exit 1; } if grep -q "No status item" "$tool/logs/log.txt"; then cat "$tool/logs/log.txt" echo "::error::The tray has no status item, so nothing pumps the Apple events sent to it" From ff28698e5297ac6b57b437103b3830e86d96fb21 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 5 Nov 2026 18:39:07 +1100 Subject: [PATCH 3/4] Send a logout's quit reason as an attribute, where loginwindow puts it send-quit set kAEQuitReason as a parameter of the quit event, and TerminateGuard reads it as an attribute, so the logout's quit read as an ordinary one and was refused. The install step's connection check was never wrong: the tray listens within a second, and its first line of output only arrives late because it is buffered. So it is back as it was, and only the quit step asks lsof, for the process the events go to. --- .github/scripts/send-quit.swift | 4 +++- .github/workflows/build.yml | 7 ++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/scripts/send-quit.swift b/.github/scripts/send-quit.swift index 3e9ceac8..c50164b2 100644 --- a/.github/scripts/send-quit.swift +++ b/.github/scripts/send-quit.swift @@ -22,8 +22,10 @@ let event = NSAppleEventDescriptor( returnID: AEReturnID(kAutoGenerateReturnID), transactionID: AETransactionID(kAnyTransactionID)) +// An attribute of the event, not a parameter, which is where loginwindow puts it and where +// TerminateGuard reads it from. if arguments.count >= 3, arguments[2] == "logout" { - event.setParam( + event.setAttribute( NSAppleEventDescriptor(enumCode: OSType(kAELogOut)), forKeyword: AEKeyword(kAEQuitReason)) } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 09458209..e5d1ac92 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -425,15 +425,11 @@ jobs: test -x "$tool/viewer/$rid/DiffEngineViewer" || { echo "::error::No viewer for $rid at $tool/viewer"; exit 1; } test -f "$tool/viewer/documents/DiffEngineViewer.Documents.dll" || { echo "::error::No documents folder at $tool/viewer"; exit 1; } - # By the tray's own process id: on macOS a connection to 3492 was taken before the tray had - # so much as started, so connecting said nothing about it - echo "Listening on 3492 before the tray starts:" - lsof -nP -iTCP:3492 -sTCP:LISTEN || true "$tray/DiffEngineTray" & pid=$! listening= for i in $(seq 1 30); do - if lsof -nP -a -p "$pid" -iTCP:3492 -sTCP:LISTEN > /dev/null 2>&1; then + if (exec 3<>/dev/tcp/127.0.0.1/3492) 2>/dev/null; then listening=1 break fi @@ -460,6 +456,7 @@ jobs: marker="${TMPDIR%/}/DiffEngineTray/version.txt" quit() { + # Listening by its own process id, since that is what the events are sent to "$tray/DiffEngineTray" & pid=$! listening= From 54105f25f71005d6a8423b085fae7d473d61155a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 5 Nov 2026 19:10:16 +1100 Subject: [PATCH 4/4] Read the tray's log only where there is one A tray that starts and quits cleanly logs nothing, so there is no log.txt, and under bash -e the cat of it ended the step that had just passed, and would have ended each failing path before its error line. --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5d1ac92..5d18920a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -468,8 +468,8 @@ jobs: sleep 1 done [ -n "$listening" ] || { cat "$tool/logs/log.txt" || true; echo "::error::The tray did not listen on 3492"; exit 1; } - if grep -q "No status item" "$tool/logs/log.txt"; then - cat "$tool/logs/log.txt" + if grep -qs "No status item" "$tool/logs/log.txt"; then + cat "$tool/logs/log.txt" || true echo "::error::The tray has no status item, so nothing pumps the Apple events sent to it" exit 1 fi @@ -489,12 +489,12 @@ jobs: done if [ -z "$exited" ]; then kill "$pid" || true - cat "$tool/logs/log.txt" + cat "$tool/logs/log.txt" || true echo "::error::The tray was still running ten seconds after a ${1:-plain} quit" exit 1 fi wait "$pid" && status=0 || status=$? - [ "$status" = 0 ] || { cat "$tool/logs/log.txt"; echo "::error::The tray exited $status after a ${1:-plain} quit"; exit 1; } + [ "$status" = 0 ] || { cat "$tool/logs/log.txt" || true; echo "::error::The tray exited $status after a ${1:-plain} quit"; exit 1; } ! test -f "$marker" || { echo "::error::The tray left $marker behind after a ${1:-plain} quit"; exit 1; } } @@ -504,8 +504,8 @@ jobs: quit logout [ "$code" != -128 ] || { echo "::error::A logout's quit was refused (-128), which cancels the logout"; exit 1; } - cat "$tool/logs/log.txt" - if grep -q "Failed while the session was ending" "$tool/logs/log.txt"; then + cat "$tool/logs/log.txt" || true + if grep -qs "Failed while the session was ending" "$tool/logs/log.txt"; then echo "::error::The session end failed" exit 1 fi