Skip to content

Commit 372d041

Browse files
authored
feat(desktop): require consent for application updates (#161)
## Related Issue Owner-directed implementation; no separate issue. ## Problem Desktop update checks could start a download, and a downloaded update could install during a normal quit. The app also did not give users one durable place to see release details, download progress, skipped versions, or restart state. ## What changed - Make every automatic and manual check discovery-only. Only **Download update** can download, and only **Restart to update** can install. - Add version-specific skip, notification, pending-install, and completion receipts. A normal quit never installs an update. - Add an update notification and **Settings > Advanced > Version & updates** states for checking, fetching, live progress, downloaded, skipped, disabled, and errors. - Stop active desktop work through the normal shutdown coordinator before installation. A shutdown failure cancels installation. - Validate macOS and Windows signatures plus update manifest version, URLs, sizes, and SHA-512 values before a draft release can publish. The next tagged Windows release requires one supported Windows signing credential set in repository secrets. The workflow fails before packaging if it is absent or incomplete. ## Verification - `pnpm run build` - `pnpm test` — 1,223 files passed; 20,360 tests passed - `pnpm lint` — 0 errors; 3,230 existing warnings - `pnpm run typecheck` - `nix build .#pythinker-code` - `node scripts/check-nix-workspace.mjs` — all 18 workspaces present - `pnpm run check:web` — 670 Linux-canonical shipped assets match source fingerprint - `pnpm --dir docs run build` - Independent security review and public-identifier audit: no findings ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] This is owner-directed work; no related issue is required. - [x] I have added tests that prove the feature works. - [x] Ran `gen-changesets` skill; added a patch changeset. - [x] Ran `gen-docs` skill; updated and built the desktop guide. Changelog sync waits for the release, as required by `sync-changelog`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added explicit desktop update controls for checking, downloading, pausing/skipping, viewing release notes, and restarting to install. * Added live download progress, transfer details, update status, retry actions, and completion notifications. * Added update management controls and diagnostics to Settings. * **Bug Fixes** * Improved safe shutdown and cleanup before installing updates. * Strengthened desktop release signing and artifact verification for macOS and Windows. * **Documentation** * Clarified update behavior, signing, verification, and restart requirements. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6d27550 commit 372d041

123 files changed

Lines changed: 2825 additions & 789 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Add explicit download and restart controls with live progress for desktop updates.

.github/workflows/desktop-release.yml

Lines changed: 155 additions & 76 deletions
Large diffs are not rendered by default.

apps/desktop/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,19 @@ rmdir "$MOUNT_POINT"
6666

6767
### Windows
6868

69-
Run `pnpm run dist:win` on a native Windows x64 host; cross-building from macOS is not possible because the staged Host closure contains platform-gated native packages. The output is `dist/Pythinker-<version>-x64-Setup.exe`, an assisted NSIS installer that defaults to a per-user install, offers a per-machine option that requires elevation, and lets you select the installation directory. The existing certificate-file signing path uses `WIN_CSC_LINK` and `WIN_CSC_KEY_PASSWORD`.
69+
Run `pnpm run dist:win` on a native Windows x64 host; cross-building from macOS is not possible because the staged Host closure contains platform-gated native packages. The output is `dist/Pythinker-<version>-x64-Setup.exe`, an assisted NSIS installer that defaults to a per-user install, offers a per-machine option that requires elevation, and lets you select the installation directory. The certificate-file signing path uses `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, and `WINDOWS_SIGNING_PUBLISHER_NAME`. The full publisher name is stored in the packaged updater configuration so electron-updater verifies future installers against it.
7070

7171
#### Azure Artifact Signing
7272

73-
Windows artifacts are signed through Azure Artifact Signing when `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_CERT_PROFILE`, and `AZURE_SIGNING_PUBLISHER_NAME` are all set; they are unsigned when none are set. The credential variables are read from the environment; the four `AZURE_SIGNING_*` variables map to `azureSignOptions.endpoint`, `azureSignOptions.codeSigningAccountName`, `azureSignOptions.certificateProfileName`, and `azureSignOptions.publisherName`, respectively. Setting only some of the seven variables is a hard error by design.
73+
Windows artifacts are signed through Azure Artifact Signing when `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_CERT_PROFILE`, and `AZURE_SIGNING_PUBLISHER_NAME` are all set; they are unsigned when neither signing method is set. The credential variables are read from the environment; the four `AZURE_SIGNING_*` variables map to `azureSignOptions.endpoint`, `azureSignOptions.codeSigningAccountName`, `azureSignOptions.certificateProfileName`, and `azureSignOptions.publisherName`, respectively. Setting only part of either signing method, or setting both methods, is a hard error.
74+
75+
Tagged releases require one complete Windows signing method. CI verifies the installer and packaged app with electron-updater's Authenticode verifier before upload. Both platform jobs also recompute every size and SHA-512 value in `latest.yml` or `latest-mac.yml`. The final job downloads the draft assets and repeats both manifest checks before publication. Manual workflow runs remain private workflow artifacts and cannot publish an unsigned build.
7476

7577
## Known limitations
7678

7779
The first desktop assembly uses a loopback HTTP Host. The renderer and Host protocol remain unchanged so the application can replace the transport with the IPC carrier reserved by the GUI architecture without changing product features.
7880

79-
The signed installer path currently targets macOS. Linux packaging creates an unpacked application; its installer format and distribution signing remain release work.
81+
Linux packaging creates an unpacked application; its installer format and distribution signing remain release work.
8082

8183
## Model Experience
8284

apps/desktop/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
"devDependencies": {
1919
"@pymodel/pythinker-telemetry": "workspace:*",
2020
"@types/node": "^26.1.2",
21+
"@types/semver": "^7.7.0",
2122
"electron": "43.4.0",
2223
"electron-builder": "26.15.3",
23-
"electron-updater": "^6.8.9",
24+
"electron-updater": "6.8.9",
25+
"semver": "^7.7.4",
2426
"tsdown": "0.22.3",
2527
"typescript": "6.0.3",
2628
"vitest": "4.1.9"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** Fail a tagged desktop release unless Windows signing is complete. */
2+
3+
import { requireWindowsReleaseSigning } from './package-win'
4+
5+
try {
6+
console.log(`Windows release signing is configured for ${requireWindowsReleaseSigning(process.env)}`)
7+
} catch (error) {
8+
console.error(error instanceof Error ? error.message : String(error))
9+
process.exit(1)
10+
}

apps/desktop/scripts/package-win.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,49 @@ export function windowsSigningArgs(env: NodeJS.ProcessEnv): readonly string[] {
4646
}
4747
missing.sort()
4848

49-
if (missing.length === values.length) return []
50-
if (missing.length > 0) {
49+
const certificateValues: readonly (readonly [string, string | undefined])[] = [
50+
['WIN_CSC_LINK', trimmedValue(env['WIN_CSC_LINK'])],
51+
['WIN_CSC_KEY_PASSWORD', trimmedValue(env['WIN_CSC_KEY_PASSWORD'])],
52+
['WINDOWS_SIGNING_PUBLISHER_NAME', trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])],
53+
]
54+
const missingCertificateValues = certificateValues
55+
.filter(([, value]) => value === undefined)
56+
.map(([name]) => name)
57+
const hasCertificateValue = missingCertificateValues.length < certificateValues.length
58+
const hasAzureValue = missing.length < values.length
59+
60+
if (hasCertificateValue && missingCertificateValues.length > 0) {
61+
throw new Error(
62+
`Windows certificate signing is partially configured; missing: ${missingCertificateValues.join(', ')}. Set all three signing variables or none.`,
63+
)
64+
}
65+
if (hasAzureValue && hasCertificateValue) {
66+
throw new Error('Choose one Windows signing method; Azure and certificate signing are both configured.')
67+
}
68+
69+
if (!hasAzureValue && !hasCertificateValue) return []
70+
if (hasAzureValue && missing.length > 0) {
5171
throw new Error(
5272
`Windows signing is partially configured; missing: ${missing.join(', ')}. Set all seven signing variables or none.`,
5373
)
5474
}
5575

76+
const publisherName = hasAzureValue
77+
? trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])!
78+
: trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])!
79+
if (!hasAzureValue) args.length = 0
80+
args.push('--config.win.publisherName', publisherName)
5681
return args
5782
}
5883

84+
/** Require one complete signing method for a tagged Windows release. */
85+
export function requireWindowsReleaseSigning(env: NodeJS.ProcessEnv): string {
86+
const args = windowsSigningArgs(env)
87+
if (args.length === 0) throw new Error('Windows release signing is not configured')
88+
return trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])
89+
?? trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])!
90+
}
91+
5992
/** Return the package-manager invocation for a Windows installer build. */
6093
export function windowsPackageInvocation(platform: string, env: NodeJS.ProcessEnv, publish: string): {
6194
readonly command: string
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/** Verify every file referenced by an electron-updater release manifest. */
2+
3+
import { createHash } from 'node:crypto'
4+
import { createReadStream } from 'node:fs'
5+
import { lstat, readFile } from 'node:fs/promises'
6+
import { basename, join, resolve } from 'node:path'
7+
import { pathToFileURL, fileURLToPath } from 'node:url'
8+
import { getFileList, parseUpdateInfo } from 'electron-updater/out/providers/Provider.js'
9+
10+
export interface VerifyUpdateManifestOptions {
11+
readonly artifactsDir: string
12+
readonly expectedVersion: string
13+
readonly platform: 'mac' | 'win'
14+
}
15+
16+
function assertSafeFilename(filename: string): void {
17+
if (
18+
basename(filename) !== filename
19+
|| !/^[A-Za-z0-9][A-Za-z0-9._+()-]*$/u.test(filename)
20+
) throw new Error(`Update manifest contains an unsafe artifact URL: ${filename}`)
21+
}
22+
23+
async function sha512(path: string): Promise<string> {
24+
const hash = createHash('sha512')
25+
for await (const chunk of createReadStream(path)) hash.update(chunk)
26+
return hash.digest('base64')
27+
}
28+
29+
/** Validate version, file references, sizes, checksums, aliases, and release date. */
30+
export async function verifyUpdateManifest(options: VerifyUpdateManifestOptions): Promise<void> {
31+
const manifestName = options.platform === 'mac' ? 'latest-mac.yml' : 'latest.yml'
32+
const manifestPath = join(options.artifactsDir, manifestName)
33+
const raw = await readFile(manifestPath, 'utf8')
34+
const info = parseUpdateInfo(raw, manifestName, pathToFileURL(manifestPath))
35+
if (info.version !== options.expectedVersion) {
36+
throw new Error(`${manifestName} version ${info.version} does not match ${options.expectedVersion}`)
37+
}
38+
39+
const releaseDate = info.releaseDate
40+
if (
41+
typeof releaseDate !== 'string'
42+
|| Number.isNaN(Date.parse(releaseDate))
43+
|| new Date(releaseDate).toISOString() !== releaseDate
44+
) throw new Error(`${manifestName} has an invalid releaseDate`)
45+
46+
const files = getFileList(info)
47+
const seen = new Set<string>()
48+
for (const file of files) {
49+
assertSafeFilename(file.url)
50+
if (seen.has(file.url)) throw new Error(`${manifestName} contains a duplicate artifact URL: ${file.url}`)
51+
seen.add(file.url)
52+
if (!Number.isSafeInteger(file.size) || (file.size ?? 0) <= 0) {
53+
throw new Error(`${manifestName} has an invalid size for ${file.url}`)
54+
}
55+
if (typeof file.sha512 !== 'string' || Buffer.from(file.sha512, 'base64').byteLength !== 64) {
56+
throw new Error(`${manifestName} has an invalid SHA-512 for ${file.url}`)
57+
}
58+
59+
const artifactPath = join(options.artifactsDir, file.url)
60+
const artifact = await lstat(artifactPath)
61+
if (!artifact.isFile()) throw new Error(`Update artifact is not a regular file: ${file.url}`)
62+
if (artifact.size !== file.size) throw new Error(`${file.url} size does not match ${manifestName}`)
63+
if (await sha512(artifactPath) !== file.sha512) {
64+
throw new Error(`${file.url} SHA-512 does not match ${manifestName}`)
65+
}
66+
}
67+
68+
const required = options.platform === 'mac'
69+
? [['-mac.zip', 'macOS ZIP'], ['.dmg', 'macOS DMG']] as const
70+
: [['-Setup.exe', 'Windows installer']] as const
71+
for (const [suffix, label] of required) {
72+
if (![...seen].some(filename => filename.endsWith(suffix))) {
73+
throw new Error(`${manifestName} does not reference the required ${label}`)
74+
}
75+
}
76+
77+
const legacy = info as typeof info & { readonly path?: unknown; readonly sha512?: unknown }
78+
if (typeof legacy.path !== 'string' || !seen.has(legacy.path)) {
79+
throw new Error(`${manifestName} top-level path does not match a file entry`)
80+
}
81+
const pathEntry = files.find(file => file.url === legacy.path)!
82+
if (legacy.sha512 !== pathEntry.sha512) {
83+
throw new Error(`${manifestName} top-level SHA-512 does not match ${legacy.path}`)
84+
}
85+
const requiredAliasSuffix = options.platform === 'mac' ? '-mac.zip' : '-Setup.exe'
86+
if (!legacy.path.endsWith(requiredAliasSuffix)) {
87+
throw new Error(`${manifestName} top-level path must reference ${requiredAliasSuffix}`)
88+
}
89+
}
90+
91+
async function main(): Promise<void> {
92+
const [platform, artifactsDir, expectedVersion] = process.argv.slice(2)
93+
if ((platform !== 'mac' && platform !== 'win') || artifactsDir === undefined || expectedVersion === undefined) {
94+
throw new Error('Usage: verify-update-manifest.ts <mac|win> <artifacts-directory> <version>')
95+
}
96+
await verifyUpdateManifest({ artifactsDir: resolve(artifactsDir), expectedVersion, platform })
97+
console.log(`${platform} update manifest verified for ${expectedVersion}`)
98+
}
99+
100+
const invokedPath = process.argv[1]
101+
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
102+
void main().catch((error: unknown) => {
103+
console.error(error instanceof Error ? error.message : String(error))
104+
process.exitCode = 1
105+
})
106+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/** Verify Windows release signatures with electron-updater's production verifier. */
2+
3+
import { readFileSync } from 'node:fs'
4+
import { dirname, join, resolve } from 'node:path'
5+
import { fileURLToPath, pathToFileURL } from 'node:url'
6+
import { verifySignature } from 'electron-updater/out/windowsExecutableCodeSignatureVerifier.js'
7+
import { parseUpdateInfo } from 'electron-updater/out/providers/Provider.js'
8+
import { verifyWindowsInstaller } from './verify-win-installer'
9+
10+
export type WindowsSignatureVerifier = (
11+
publisherNames: string[],
12+
path: string,
13+
) => Promise<string | null>
14+
15+
/** Verify the installer and packaged application against the updater publisher. */
16+
export async function verifyWindowsSignatures(
17+
desktopRoot: string,
18+
publisherName: string,
19+
verifier: WindowsSignatureVerifier = (names, path) => verifySignature(names, path, console),
20+
): Promise<void> {
21+
const publisher = publisherName.trim()
22+
if (publisher === '') throw new Error('Windows signing publisher is empty')
23+
verifyWindowsInstaller(desktopRoot)
24+
const { version } = JSON.parse(readFileSync(join(desktopRoot, 'package.json'), 'utf8')) as { version: string }
25+
const appUpdatePath = join(desktopRoot, 'dist', 'win-unpacked', 'resources', 'app-update.yml')
26+
const appUpdate = parseUpdateInfo(
27+
readFileSync(appUpdatePath, 'utf8'),
28+
'app-update.yml',
29+
pathToFileURL(appUpdatePath),
30+
) as unknown as { readonly publisherName?: string | readonly string[] }
31+
const configuredPublishers = typeof appUpdate.publisherName === 'string'
32+
? [appUpdate.publisherName]
33+
: appUpdate.publisherName
34+
const normalizedPublishers = configuredPublishers?.map((value) => value.trim())
35+
if (normalizedPublishers === undefined || normalizedPublishers.length !== 1) {
36+
throw new Error('Packaged updater configuration does not exactly match the expected Windows publisher')
37+
}
38+
if (normalizedPublishers[0] !== publisher) {
39+
throw new Error('Packaged updater configuration does not contain the expected Windows publisher')
40+
}
41+
const paths = [
42+
join(desktopRoot, 'dist', `Pythinker-${version}-x64-Setup.exe`),
43+
join(desktopRoot, 'dist', 'win-unpacked', 'Pythinker.exe'),
44+
]
45+
for (const path of paths) {
46+
const error = await verifier([publisher], path)
47+
if (error !== null) throw new Error(`Windows signature verification failed for ${path}: ${error}`)
48+
}
49+
}
50+
51+
async function main(): Promise<void> {
52+
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
53+
const publisher = process.env['AZURE_SIGNING_PUBLISHER_NAME']
54+
?? process.env['WINDOWS_SIGNING_PUBLISHER_NAME']
55+
if (publisher === undefined) throw new Error('Windows signing publisher is not configured')
56+
await verifyWindowsSignatures(desktopRoot, publisher)
57+
console.log(`Windows release signatures verified for ${publisher}`)
58+
}
59+
60+
const invokedPath = process.argv[1]
61+
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
62+
void main().catch((error: unknown) => {
63+
console.error(error instanceof Error ? error.message : String(error))
64+
process.exitCode = 1
65+
})
66+
}

apps/desktop/src/host-supervisor.ts

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Readable } from 'node:stream'
77
const READINESS_PREFIX = 'Pythinker server: '
88
const DEFAULT_READINESS_TIMEOUT_MS = 90_000
99
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
10+
const HOST_SHUTDOWN_REQUEST_TIMEOUT_MS = 1_000
1011
const TASKKILL_TIMEOUT_MS = 5_000
1112
const MAX_STARTUP_OUTPUT_CHARS = 32_768
1213

@@ -165,6 +166,8 @@ export interface HostSupervisorOptions {
165166
readonly readinessTimeoutMs?: number
166167
/** Grace after SIGTERM before SIGKILL. */
167168
readonly shutdownTimeoutMs?: number
169+
/** Ask a ready Host to drain and stop before process-signal fallback. */
170+
readonly requestShutdown?: (ready: HostReady) => Promise<void>
168171
/** Receives bounded Host output for desktop diagnostics. */
169172
readonly log?: (line: string) => void
170173
/** Called when a ready Host exits outside an application-owned shutdown. */
@@ -195,6 +198,38 @@ function deferred<T>(): Deferred<T> {
195198
return { promise, resolve, reject }
196199
}
197200

201+
async function waitForExit(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
202+
let timer: ReturnType<typeof setTimeout> | undefined
203+
const outcome = await Promise.race([
204+
exited.then(() => true),
205+
new Promise<false>((resolve) => {
206+
timer = setTimeout(() => { resolve(false) }, timeoutMs)
207+
}),
208+
])
209+
if (timer !== undefined) clearTimeout(timer)
210+
return outcome
211+
}
212+
213+
/** Request the authenticated graceful shutdown route of a validated loopback Host. */
214+
export async function requestHostShutdown(ready: HostReady): Promise<void> {
215+
const origin = new URL(ready.origin)
216+
if (origin.protocol !== 'http:' || (origin.hostname !== '127.0.0.1' && origin.hostname !== 'localhost')) {
217+
throw new Error('desktop Host shutdown requires a loopback HTTP origin')
218+
}
219+
const controller = new AbortController()
220+
const timer = setTimeout(() => { controller.abort() }, HOST_SHUTDOWN_REQUEST_TIMEOUT_MS)
221+
try {
222+
const response = await fetch(new URL('/api/v1/shutdown', origin), {
223+
method: 'POST',
224+
headers: ready.token === undefined ? undefined : { Authorization: `Bearer ${ready.token}` },
225+
signal: controller.signal,
226+
})
227+
if (!response.ok) throw new Error(`desktop Host shutdown returned HTTP ${String(response.status)}`)
228+
} finally {
229+
clearTimeout(timer)
230+
}
231+
}
232+
198233
/**
199234
* Create a single-owner Host supervisor.
200235
* @param options - Child-process operations and bounded lifecycle timings.
@@ -208,6 +243,7 @@ export function createHostSupervisor(options: HostSupervisorOptions): HostSuperv
208243
let shutdownPromise: Promise<void> | undefined
209244
let exited: Promise<void> | undefined
210245
let exitResult: Deferred<void> | undefined
246+
let readyInfo: HostReady | undefined
211247
let ready = false
212248
let shuttingDown = false
213249
let output = ''
@@ -255,6 +291,7 @@ export function createHostSupervisor(options: HostSupervisorOptions): HostSuperv
255291
if (url === undefined || settled) return
256292
settled = true
257293
ready = true
294+
readyInfo = url
258295
cleanupStartup()
259296
resolve(url)
260297
} catch (error) {
@@ -290,22 +327,19 @@ export function createHostSupervisor(options: HostSupervisorOptions): HostSuperv
290327
const spawned = child
291328
if (spawned === undefined) return
292329
shuttingDown = true
293-
spawned.kill('SIGTERM')
294330
const closed = exited ?? Promise.resolve()
295-
let timer: ReturnType<typeof setTimeout> | undefined
296-
const outcome = await Promise.race([
297-
closed.then(() => 'closed' as const),
298-
new Promise<'timeout'>((resolve) => {
299-
timer = setTimeout(() => {
300-
resolve('timeout')
301-
}, shutdownTimeoutMs)
302-
}),
303-
])
304-
if (timer !== undefined) clearTimeout(timer)
305-
if (outcome === 'timeout') {
306-
spawned.kill('SIGKILL')
307-
await closed
331+
if (readyInfo !== undefined && options.requestShutdown !== undefined) {
332+
try {
333+
await options.requestShutdown(readyInfo)
334+
if (await waitForExit(closed, shutdownTimeoutMs)) return
335+
} catch {
336+
// Signal fallback handles unavailable or rejected shutdown requests.
337+
}
308338
}
339+
spawned.kill('SIGTERM')
340+
if (await waitForExit(closed, shutdownTimeoutMs)) return
341+
spawned.kill('SIGKILL')
342+
await closed
309343
})()
310344
return shutdownPromise
311345
}
@@ -399,9 +433,8 @@ export function spawnPythinkerServer(options: SpawnPythinkerServerOptions): Host
399433
* Because this call is synchronous, keep it bounded so a stalled taskkill falls
400434
* back to a single-process kill instead of blocking the Electron main loop indefinitely.
401435
*
402-
* ponytail: /F makes every Windows stop a forced stop — Node cannot deliver a
403-
* graceful SIGTERM to a Windows child at all. Add a stdin or IPC shutdown
404-
* channel to the Host if graceful Windows teardown is ever needed.
436+
* The supervisor first uses the authenticated HTTP shutdown route. `/F` is
437+
* only the bounded fallback when the Host does not drain and exit.
405438
*/
406439
function killProcessTree(child: ChildProcessByStdio<null, Readable, Readable>, signal: 'SIGTERM' | 'SIGKILL'): void {
407440
if (process.platform !== 'win32' || child.pid === undefined) {

0 commit comments

Comments
 (0)