You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
macOS in-app OTA hangs at 100% and the sheet cannot be dismissed (v0.1.0 and v0.1.1 both reproduce) #13
Real-machine drill on macOS arm64 (macOS 26, app installed to /Applications): clicking 更新立即 / "Update Now" in the update sheet downloads the artifact to 100%, then the sheet stays on "正在下载新版本… 100%" forever. Reproduced on both v0.1.0 and v0.1.1 clients upgrading to the live 0.1.2 feed.
The sheet is interactiveDismissDisabled during .downloading, so the user cannot even close it — the app must be killed.
The v0.1.1 dual watchdogs (120s stall / 30min total) do not fire on this path — they live in the backend updater (racket/taskly/updater.rkt), but the macOS host downloads via the Swift UpdateService.downloadAndSwap, which has no watchdog.
Evidence (v0.1.1 client, ~4 min after clicking Update)
sample of the process shows no URLSession/CFNetwork frames and no cooperative-pool thread running the download — the task is suspended on a continuation that never resumes; the TCP connection is already gone (lsof shows no sockets).
No taskly-update-* work dir remains in TMPDIR (the defer cleanup ran or the function never got past it), no partial zip anywhere.
The backend diagnostic log has no update-related errors — the Swift path never reports the failure back.
The Racket-side download of the same URL (rivet/distributiondownload-update, i.e. the path Windows/Linux use) completes in seconds with a valid SHA-256, headless, on the same machine — so the feed, CDN redirect chain, and artifact are fine.
Suspected root cause
The known URLSession.bytes AsyncBytes trap the code comment itself names — "end-of-stream flag never surfaces to AsyncBytes". The v0.1.1 size-break (if data.count >= total { break }) was meant to bypass it, but on this real machine it did not prevent the hang (progress did reach 100%, so data.count >= total was observed at least once; the task still ended up suspended — either the break-vs-next interleave or a post-loop await never resumes). The UI then has no timeout to fall back on.
Suggested fix direction
Replace AsyncBytes consumption with a URLSessionDownloadDelegate-based file download (delegate gets definitive didFinish/didFail callbacks), keeping the SHA-256 verification against the signed manifest afterwards.
Or keep AsyncBytes but wrap the whole downloadAndInstall in a finite timeout (e.g. 10 min) and surface a .failed phase.
Make the update sheet dismissible (or offer a Cancel that cancels the task) even during .downloading.
The backend watchdogs are dead code on macOS — either delete them from the mac path's expectations or add an equivalent host-side watchdog.
Repro steps
Install taskly 0.1.0 or 0.1.1 (macOS arm64) to /Applications.
Launch, wait for the auto check (or 设置 ▸ 检查更新…), click 立即更新.
Upstream progress: the fixed host-side flow (delegate-driven downloader replacing the AsyncBytes loop, deterministic size gate, cancellation escape hatch) is being extracted into rivet as a first-party shared Swift module — rivet#230 (RivetUpdate, with 28 unit tests covering the EOF/stuck-continuation class of failure). Once it merges, taskly's hand-rolled UpdateService.swift should be replaced by that module so the whole family shares one implementation.
Also spotted on main while auditing: AppModel.swift:128 still carries the DRILL local feed override (http://127.0.0.1:8787/update-manifest.json) — that must not ship in the next release. The adoption diff will remove it.
Fixed on main in 27ab8c6 (this issue auto-closes via the commit reference).
Beyond the suspected AsyncBytes EOF trap, the real-machine drill exposed three coupled defects, all fixed and verified:
Download leg (the original report): replaced AsyncBytes byte-iteration with a delegate-driven URLSessionDownloadTask — didFinishDownloadingTo/didCompleteWithError are definitive, a silent stall now hits the request timeout (~60 s) and surfaces a retryable error instead of parking the continuation forever. sha256 is verified from the file on disk in 1 MiB chunks. The sheet gained a Cancel button during download; cancelling invalidates the session and the phase machine settles back to idle.
Swap handoff: a presented SwiftUI sheet silently vetoes NSApplication.terminate — the old process survived, kept the single-instance lease, and the relaunched bundle died on it. Quit now ends every sheet first with a run-loop stop fallback, and the swap script waits out the lease before open.
Payload race: the function's defer deleted the swap work dir while the sleeping swap script still needed the unpacked bundle out of it. Cleanup is now the script's job on success, and a later launch sweeps stale work dirs.
full chain ×2: check → Ed25519-verified manifest → download → size+sha256 → unpack → version check → atomic swap → relaunch — stable single process of the new version, no .old or work-dir residue;
cancel mid-download at ~10%: sheet closes cleanly, app keeps running;
stall: throttled transfer stalls at 17% → retryable error after ~60 s;
live GitHub feed verified for the check leg; artifact/CDN unchanged (a headless Racket download of the same URL always completed, which is what pinned this to the host's Swift leg).
Correction on my earlier note: the DRILL local-feed override (http://127.0.0.1:8787 in AppModel.swift) was already removed by 27ab8c6 — nothing left to clean up there. The rivet#230 upstreaming note stands.
Activity
turinglambdaai commentedon Oct 13, 2026
Upstream progress: the fixed host-side flow (delegate-driven downloader replacing the AsyncBytes loop, deterministic size gate, cancellation escape hatch) is being extracted into rivet as a first-party shared Swift module — rivet#230 (
RivetUpdate, with 28 unit tests covering the EOF/stuck-continuation class of failure). Once it merges, taskly's hand-rolledUpdateService.swiftshould be replaced by that module so the whole family shares one implementation.Also spotted on main while auditing:
AppModel.swift:128still carries the DRILL local feed override (http://127.0.0.1:8787/update-manifest.json) — that must not ship in the next release. The adoption diff will remove it.turinglambdaai commentedon Oct 13, 2026
Fixed on main in 27ab8c6 (this issue auto-closes via the commit reference).
Beyond the suspected AsyncBytes EOF trap, the real-machine drill exposed three coupled defects, all fixed and verified:
URLSessionDownloadTask—didFinishDownloadingTo/didCompleteWithErrorare definitive, a silent stall now hits the request timeout (~60 s) and surfaces a retryable error instead of parking the continuation forever. sha256 is verified from the file on disk in 1 MiB chunks. The sheet gained a Cancel button during download; cancelling invalidates the session and the phase machine settles back to idle.NSApplication.terminate— the old process survived, kept the single-instance lease, and the relaunched bundle died on it. Quit now ends every sheet first with a run-loop stop fallback, and the swap script waits out the lease beforeopen.deferdeleted the swap work dir while the sleeping swap script still needed the unpacked bundle out of it. Cleanup is now the script's job on success, and a later launch sweeps stale work dirs.Verification matrix (real machine, arm64, /Applications install):
.oldor work-dir residue;turinglambdaai commentedon Oct 13, 2026
Correction on my earlier note: the DRILL local-feed override (
http://127.0.0.1:8787inAppModel.swift) was already removed by 27ab8c6 — nothing left to clean up there. The rivet#230 upstreaming note stands.Release 0.1.3: the macOS in-app update that actually completes