Repository navigation
fix(desktop): verified update teardown on Windows+WSL, install integrity check, and resilient WSL readiness handshake #5998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Jgratton24
wants to merge
9
commits into
pingdotgg:main
from
Jgratton24:fix/update-apply-wsl-handshake
Closed
Changes from 1 commit
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
f49d4ea
fix(desktop): atomic update apply on Windows+WSL and bounded WSL read…
Jgratton24 d06db2d
fix(desktop): address Macroscope review feedback
Jgratton24 23a36a8
fix(desktop): harden integrity check and bound Windows-primary readin…
Jgratton24 d3c3ab1
fix(desktop): only NotFound counts as a missing manifest; unverified …
Jgratton24 02343e8
fix(desktop): cap consecutive never-ready backend exits and surface i…
Jgratton24 61c09f0
fix(desktop): exclude deliberate readiness terminations from the neve…
Jgratton24 3b1e597
fix(desktop): gate the primary WSL-fallback branch on the effective W…
Jgratton24 0fdb80d
docs(desktop): align lock-mechanism comments with empirical findings
Jgratton24 249a6d9
fix(desktop): run the WSL install-dir release check as root
Jgratton24 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
| import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Path from "effect/Path"; | ||
|
|
||
| import * as DesktopConfig from "./DesktopConfig.ts"; | ||
| import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| import * as DesktopInstallIntegrity from "./DesktopInstallIntegrity.ts"; | ||
|
|
||
| const makeEnvironmentLayer = (input: { | ||
| readonly resourcesPath: string; | ||
| readonly isPackaged: boolean; | ||
| readonly homeDirectory: string; | ||
| }) => | ||
| DesktopEnvironment.layer({ | ||
| dirname: "/repo/apps/desktop/src", | ||
| homeDirectory: input.homeDirectory, | ||
| platform: "win32", | ||
| processArch: "x64", | ||
| appVersion: "1.2.3", | ||
| appPath: "/repo", | ||
| isPackaged: input.isPackaged, | ||
| resourcesPath: input.resourcesPath, | ||
| runningUnderArm64Translation: false, | ||
| }).pipe( | ||
| Layer.provide( | ||
| Layer.mergeAll( | ||
| NodeServices.layer, | ||
| DesktopConfig.layerTest({ | ||
| T3CODE_HOME: input.homeDirectory, | ||
| }), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| const withInstallDir = <A, E, R>( | ||
| build: (input: { | ||
| readonly resourcesPath: string; | ||
| readonly manifestPath: string; | ||
| }) => Effect.Effect<A, E, R>, | ||
| ) => | ||
| Effect.gen(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| prefix: "t3-desktop-install-integrity-test-", | ||
| }); | ||
| const resourcesPath = path.join(baseDir, "resources"); | ||
| const manifestPath = path.join( | ||
| resourcesPath, | ||
| ...DesktopInstallIntegrity.DESKTOP_BUILD_MANIFEST_RELATIVE_PATH.split("/"), | ||
| ); | ||
| yield* fileSystem.makeDirectory(path.dirname(manifestPath), { recursive: true }); | ||
| return yield* build({ resourcesPath, manifestPath }); | ||
| }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); | ||
|
|
||
| describe("DesktopInstallIntegrity", () => { | ||
| it.effect("accepts an install whose unpacked build stamp matches the asar version", () => | ||
| withInstallDir(({ resourcesPath, manifestPath }) => | ||
| Effect.gen(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| yield* fileSystem.writeFileString(manifestPath, '{"version":"1.2.3"}\n'); | ||
|
|
||
| const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( | ||
| Effect.provide( | ||
| makeEnvironmentLayer({ | ||
| resourcesPath, | ||
| isPackaged: true, | ||
| homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, | ||
| }), | ||
| ), | ||
| ); | ||
| assert.equal(result._tag, "Ok"); | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| it.effect("flags a half-applied update whose unpacked files came from a different build", () => | ||
| withInstallDir(({ resourcesPath, manifestPath }) => | ||
| Effect.gen(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| // The observed corruption: app.asar (and its version) stayed at the | ||
| // old build while app.asar.unpacked was replaced by the new one. | ||
| yield* fileSystem.writeFileString(manifestPath, '{"version":"1.2.4-nightly.1"}\n'); | ||
|
|
||
| const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( | ||
| Effect.provide( | ||
| makeEnvironmentLayer({ | ||
| resourcesPath, | ||
| isPackaged: true, | ||
| homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, | ||
| }), | ||
| ), | ||
| ); | ||
| assert.equal(result._tag, "Mismatch"); | ||
| if (result._tag === "Mismatch") { | ||
| assert.equal(result.appVersion, "1.2.3"); | ||
| assert.equal(result.unpackedVersion, "1.2.4-nightly.1"); | ||
| } | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| it.effect("skips when no manifest exists (pre-stamp installs, macOS/Linux, dev)", () => | ||
| withInstallDir(({ resourcesPath }) => | ||
| Effect.gen(function* () { | ||
| const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( | ||
| Effect.provide( | ||
| makeEnvironmentLayer({ | ||
| resourcesPath, | ||
| isPackaged: true, | ||
| homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, | ||
| }), | ||
| ), | ||
| ); | ||
| assert.equal(result._tag, "Skipped"); | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| it.effect("skips undecodable manifests instead of blocking launch", () => | ||
| withInstallDir(({ resourcesPath, manifestPath }) => | ||
| Effect.gen(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| yield* fileSystem.writeFileString(manifestPath, "not json"); | ||
|
|
||
| const result = yield* DesktopInstallIntegrity.checkInstallIntegrity.pipe( | ||
| Effect.provide( | ||
| makeEnvironmentLayer({ | ||
| resourcesPath, | ||
| isPackaged: true, | ||
| homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, | ||
| }), | ||
| ), | ||
| ); | ||
| assert.equal(result._tag, "Skipped"); | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| it.effect("resolves the releases page from the packaged update feed", () => | ||
| withInstallDir(({ resourcesPath }) => | ||
| Effect.gen(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const environmentLayer = makeEnvironmentLayer({ | ||
| resourcesPath, | ||
| isPackaged: true, | ||
| homeDirectory: `/tmp/t3-integrity-home-${process.pid}`, | ||
| }); | ||
| const appUpdateYmlPath = yield* DesktopEnvironment.DesktopEnvironment.pipe( | ||
| Effect.map((environment) => environment.appUpdateYmlPath), | ||
| Effect.provide(environmentLayer), | ||
| ); | ||
| yield* fileSystem.makeDirectory( | ||
| appUpdateYmlPath.slice(0, appUpdateYmlPath.lastIndexOf("/")), | ||
| { recursive: true }, | ||
| ); | ||
| yield* fileSystem.writeFileString( | ||
| appUpdateYmlPath, | ||
| "provider: github\nowner: pingdotgg\nrepo: t3code\n", | ||
| ); | ||
|
|
||
| const url = yield* DesktopInstallIntegrity.resolveDownloadPageUrl.pipe( | ||
| Effect.provide(environmentLayer), | ||
| ); | ||
| assert.deepEqual(url, Option.some("https://github.com/pingdotgg/t3code/releases")); | ||
| }), | ||
| ), | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| // Post-apply install integrity check. A Windows update apply is not atomic: | ||
| // NSIS replaces files one by one, and a file held open (e.g. via a WSL 9p | ||
| // handle) is silently skipped in silent-install mode, leaving app.asar and | ||
| // app.asar.unpacked from DIFFERENT builds. That state crashes later with an | ||
| // opaque ERR_MODULE_NOT_FOUND in the main process. The desktop build stamps | ||
| // apps/server/dist/desktop-build-manifest.json (asar-unpacked on Windows) | ||
| // with the build version; comparing it against the asar's own version at | ||
| // startup detects a half-applied update before anything loads from the | ||
| // mismatched halves, and turns the opaque crash into an actionable dialog. | ||
|
|
||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Ref from "effect/Ref"; | ||
| import * as Schema from "effect/Schema"; | ||
|
|
||
| import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| import * as DesktopObservability from "./DesktopObservability.ts"; | ||
| import * as DesktopShutdown from "./DesktopShutdown.ts"; | ||
| import * as DesktopState from "./DesktopState.ts"; | ||
| import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| import * as ElectronDialog from "../electron/ElectronDialog.ts"; | ||
| import * as ElectronShell from "../electron/ElectronShell.ts"; | ||
|
|
||
| // Kept in sync with scripts/build-desktop-artifact.ts, which writes the | ||
| // manifest into the staged apps/server/dist so it lands under the Windows | ||
| // asarUnpack globs. | ||
| export const DESKTOP_BUILD_MANIFEST_RELATIVE_PATH = | ||
| "app.asar.unpacked/apps/server/dist/desktop-build-manifest.json"; | ||
|
|
||
| export const DesktopBuildManifest = Schema.Struct({ | ||
| version: Schema.String, | ||
| }); | ||
| export type DesktopBuildManifest = typeof DesktopBuildManifest.Type; | ||
|
|
||
| const decodeDesktopBuildManifest = Schema.decodeUnknownEffect( | ||
| Schema.fromJsonString(DesktopBuildManifest), | ||
| ); | ||
|
|
||
| export type DesktopInstallIntegrityResult = | ||
| | { readonly _tag: "Ok" } | ||
| // No manifest to compare against: dev runs, macOS/Linux (which pack the | ||
| // server dist inside the asar), installs predating the stamp, or an | ||
| // unreadable/corrupt manifest. Never blocks launch. | ||
| | { readonly _tag: "Skipped"; readonly reason: string } | ||
| | { | ||
| readonly _tag: "Mismatch"; | ||
| readonly appVersion: string; | ||
| readonly unpackedVersion: string; | ||
| readonly manifestPath: string; | ||
| }; | ||
|
|
||
| const { logWarning: logIntegrityWarning, logError: logIntegrityError } = | ||
| DesktopObservability.makeComponentLogger("desktop-install-integrity"); | ||
|
|
||
| export const checkInstallIntegrity: Effect.Effect< | ||
| DesktopInstallIntegrityResult, | ||
| never, | ||
| DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem | ||
| > = Effect.gen(function* () { | ||
| const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
|
|
||
| if (!environment.isPackaged) { | ||
| return { _tag: "Skipped", reason: "not a packaged build" } as const; | ||
| } | ||
|
|
||
| const manifestPath = environment.path.join( | ||
| environment.resourcesPath, | ||
| ...DESKTOP_BUILD_MANIFEST_RELATIVE_PATH.split("/"), | ||
| ); | ||
| const raw = yield* fileSystem.readFileString(manifestPath, "utf-8").pipe(Effect.option); | ||
| if (Option.isNone(raw)) { | ||
| return { _tag: "Skipped", reason: `no build manifest at ${manifestPath}` } as const; | ||
| } | ||
|
Jgratton24 marked this conversation as resolved.
Outdated
|
||
|
|
||
| const manifest = yield* decodeDesktopBuildManifest(raw.value).pipe(Effect.option); | ||
| if (Option.isNone(manifest)) { | ||
| // A corrupt manifest could itself be fallout from a partial apply, but a | ||
| // false positive here bricks every launch — log loudly and let the | ||
| // version comparison stay the only hard gate. | ||
| yield* logIntegrityWarning("build manifest exists but could not be decoded", { | ||
| manifestPath, | ||
| }); | ||
| return { _tag: "Skipped", reason: `undecodable build manifest at ${manifestPath}` } as const; | ||
| } | ||
|
|
||
| if (manifest.value.version === environment.appVersion) { | ||
| return { _tag: "Ok" } as const; | ||
| } | ||
|
|
||
| return { | ||
| _tag: "Mismatch", | ||
| appVersion: environment.appVersion, | ||
| unpackedVersion: manifest.value.version, | ||
| manifestPath, | ||
| } as const; | ||
| }).pipe(Effect.withSpan("desktop.installIntegrity.check")); | ||
|
|
||
| // Best-effort "where to get a fresh installer" link for the repair dialog, | ||
| // derived from the packaged update feed (app-update.yml). None when the | ||
| // build has no feed configured. | ||
| export const resolveDownloadPageUrl: Effect.Effect< | ||
| Option.Option<string>, | ||
| never, | ||
| DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem | ||
| > = Effect.gen(function* () { | ||
| const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const raw = yield* fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe( | ||
| Effect.option, | ||
| ); | ||
| if (Option.isNone(raw)) { | ||
| return Option.none<string>(); | ||
| } | ||
| const owner = raw.value.match(/^owner:\s*(.+)$/m)?.[1]?.trim(); | ||
| const repo = raw.value.match(/^repo:\s*(.+)$/m)?.[1]?.trim(); | ||
| if (!owner || !repo) { | ||
| return Option.none<string>(); | ||
| } | ||
| return Option.some(`https://github.com/${owner}/${repo}/releases`); | ||
| }); | ||
|
|
||
| // Runs the check and, on mismatch, refuses to continue launching: shows an | ||
| // actionable dialog (with a jump to the releases page when the update feed | ||
| // names one) and requests shutdown. Returns false when startup must stop. | ||
| export const enforceInstallIntegrity = (options: { | ||
| readonly downloadPageUrl: Option.Option<string>; | ||
| }): Effect.Effect< | ||
| boolean, | ||
| never, | ||
| | DesktopEnvironment.DesktopEnvironment | ||
| | FileSystem.FileSystem | ||
| | DesktopShutdown.DesktopShutdown | ||
| | DesktopState.DesktopState | ||
| | ElectronApp.ElectronApp | ||
| | ElectronDialog.ElectronDialog | ||
| | ElectronShell.ElectronShell | ||
| > => | ||
| Effect.gen(function* () { | ||
| const result = yield* checkInstallIntegrity; | ||
| if (result._tag === "Ok") { | ||
| return true; | ||
| } | ||
| if (result._tag === "Skipped") { | ||
| return true; | ||
| } | ||
|
|
||
| const shutdown = yield* DesktopShutdown.DesktopShutdown; | ||
| const state = yield* DesktopState.DesktopState; | ||
| const electronApp = yield* ElectronApp.ElectronApp; | ||
| const electronDialog = yield* ElectronDialog.ElectronDialog; | ||
| const electronShell = yield* ElectronShell.ElectronShell; | ||
|
|
||
| yield* logIntegrityError( | ||
| "install is internally inconsistent (half-applied update); refusing to launch", | ||
| { | ||
| appVersion: result.appVersion, | ||
| unpackedVersion: result.unpackedVersion, | ||
| manifestPath: result.manifestPath, | ||
| }, | ||
| ); | ||
|
|
||
| const wasQuitting = yield* Ref.getAndSet(state.quitting, true); | ||
| if (!wasQuitting) { | ||
| const message = | ||
| `This T3 Code installation is damaged: a previous update was only partially applied ` + | ||
| `(application core is version ${result.appVersion}, support files are version ${result.unpackedVersion}). ` + | ||
| `Please reinstall T3 Code to repair it.`; | ||
| const buttons = Option.isSome(options.downloadPageUrl) | ||
| ? ["Open Download Page", "Quit"] | ||
| : ["Quit"]; | ||
| const response = yield* electronDialog | ||
| .showMessageBox({ | ||
| type: "error", | ||
| title: "T3 Code installation is damaged", | ||
| message, | ||
| buttons, | ||
| defaultId: 0, | ||
| cancelId: buttons.length - 1, | ||
| }) | ||
| .pipe(Effect.orElseSucceed(() => ({ response: buttons.length - 1 }))); | ||
| if (Option.isSome(options.downloadPageUrl) && response.response === 0) { | ||
| yield* electronShell.openExternal(options.downloadPageUrl.value).pipe(Effect.ignore); | ||
| } | ||
| } | ||
| yield* shutdown.request; | ||
| yield* electronApp.quit; | ||
| return false; | ||
| }).pipe(Effect.withSpan("desktop.installIntegrity.enforce")); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.