Compare commits

...

2 Commits

Author SHA1 Message Date
Keysat 00a4b704e8 Update Current state: 1.2.0:2 built + sideloaded
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
Record the Safari first-tap retry release as built + sideloaded, refresh
the pending on-box check (now also the first-tap proof from Safari), and
bump the local-verification numbers (213 tests).

Also folds in the pending doc-name alignment left in the working tree:
the AGENTS.md inbox tag and the EVALUATION.md title now read
"proof-of-work" instead of the old "Workout-log".
2026-06-15 16:44:58 -05:00
Keysat 0178f8f5cc v1.2.0:2 — retry login/signup server action once on transport failure
iOS Safari reuses a keep-alive socket the server closed while the login
form sat idle during typing, so the first Sign In / Create account POST
dies instantly with NSURLErrorNetworkConnectionLost ("The network
connection was lost"). That rejects the server-action call, hitting the
client-side catch in LoginForm/SignupForm and showing "An unexpected
error occurred"; the second tap lands on a fresh connection and works.

Add lib/retryAction.ts: retryOnTransportError() retries the action once
only when the call throws. A returned { error } (bad password, rate
limit) is a real result and passes straight through. A lost-on-a-stale-
socket POST never reached the server, so retrying it once is safe.
2026-06-15 16:44:33 -05:00
8 changed files with 114 additions and 7 deletions
+5 -3
View File
@@ -3,7 +3,7 @@
Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 `s9pk`, published to a private Start9 registry. Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 `s9pk`, published to a private Start9 registry.
> **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for > **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for
> items tagged `(Workout-log)` and surface them before proposing next steps; triage with `/triage`. > items tagged `(proof-of-work)` and surface them before proposing next steps; triage with `/triage`.
## Stack (versions that matter) ## Stack (versions that matter)
@@ -102,9 +102,11 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state ## Current state
Latest version is **1.2.0:1**the **Next.js 14→15 / React 18→19** upgrade (the remaining P1; closes the Next framework RSC + middleware-bypass CVEs). **Built + sideloaded** to the StartOS box (`immense-voyage.local`, 2026-06-13, on `master`) as `proof-of-work_x86_64.s9pk` (80M, git `f487204`). Verified locally before build: tsc + lint clean, **209 tests pass**, `next build` succeeds, standalone bundle traces the Prisma engine. Registry empty, **publishing parked** (sideload-only via `make install`). Latest version is **1.2.0:2****login/signup first-tap retry** for iOS Safari (Safari drops the first server-action POST on a stale keep-alive socket → `NSURLErrorNetworkConnectionLost` → client catch showed "An unexpected error occurred"; the new `lib/retryAction.ts` retries the action once on a *thrown* transport failure, while a returned `{ error }` passes through). **Built + sideloaded** to the StartOS box (`immense-voyage.local`, 2026-06-15, on `master`) as `proof-of-work_x86_64.s9pk` (80M, git `0178f8f`). Verified locally before build: tsc clean (app + packaging), lint clean (only pre-existing warnings), **213 tests pass**, `next build` succeeds. Registry empty, **publishing parked** (sideload-only via `make install`).
**Pending on-box check:** confirm 1.2.0:1 boots clean in StartOS → Logs (this supersedes the still-unconfirmed 1.1.0:9 non-root clean-boot check — same Logs verification: entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 with no permission errors). Prior shipped: **1.2.0:1** — Next.js 14→15 / React 18→19 upgrade (closed the Next RSC + middleware-bypass CVEs; async-params migration). No schema/data change in either release.
**Pending on-box check:** confirm 1.2.0:2 boots clean in StartOS → Logs, **and** the real first-tap proof — log in from Safari on iPhone/iPad and confirm the *first* Sign In tap now works (the retry can't be unit-tested against a live stale socket). If a first tap still occasionally fails, grab the Safari Web Inspector error (iPad→Mac) to confirm it's `-1005` vs a proxy↔container keep-alive mismatch. This also still covers the 1.1.0:9 non-root clean-boot check (entrypoint logs `launching … as nextjs`, app writes `/data` as uid 1001 with no permission errors).
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history). Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
+1 -1
View File
@@ -1,4 +1,4 @@
# Evaluation — proof-of-work (Workout-log) — 2026-06-13 # Evaluation — proof-of-work — 2026-06-13
Intent: A self-hosted, multi-user workout planner and logger (Next.js 14 App Router + server actions/SSE, Prisma/SQLite, bcrypt auth) with an AI program-suggestion subsystem (5 LLM providers, background generation), packaged as a StartOS 0.4 s9pk. Intent: A self-hosted, multi-user workout planner and logger (Next.js 14 App Router + server actions/SSE, Prisma/SQLite, bcrypt auth) with an AI program-suggestion subsystem (5 LLM providers, background generation), packaged as a StartOS 0.4 s9pk.
+4 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { loginAction } from './actions'; import { loginAction } from './actions';
import { retryOnTransportError } from '@/lib/retryAction';
export default function LoginForm() { export default function LoginForm() {
const router = useRouter(); const router = useRouter();
@@ -17,7 +18,9 @@ export default function LoginForm() {
setLoading(true); setLoading(true);
try { try {
const result = await loginAction(email, password); const result = await retryOnTransportError(() =>
loginAction(email, password)
);
if (result.error) { if (result.error) {
setError(result.error); setError(result.error);
+4 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { signupAction } from './actions'; import { signupAction } from './actions';
import { retryOnTransportError } from '@/lib/retryAction';
export default function SignupForm() { export default function SignupForm() {
const router = useRouter(); const router = useRouter();
@@ -19,7 +20,9 @@ export default function SignupForm() {
setLoading(true); setLoading(true);
try { try {
const result = await signupAction(email, password, passwordConfirm, name); const result = await retryOnTransportError(() =>
signupAction(email, password, passwordConfirm, name)
);
if (result.error) { if (result.error) {
setError(result.error); setError(result.error);
setLoading(false); setLoading(false);
+25
View File
@@ -0,0 +1,25 @@
/**
* Run a server action, retrying it ONCE if the call rejects at the
* transport layer.
*
* iOS Safari (and Safari generally) frequently drops the first POST sent
* on a keep-alive socket that the server closed while the connection sat
* idle — e.g. while the user typed their credentials. The request fails
* instantly with `NSURLErrorNetworkConnectionLost` ("The network
* connection was lost", -1005); a retry lands on a fresh connection and
* succeeds. This is why a first login/signup tap shows "An unexpected
* error occurred" and the second tap works.
*
* Only a *thrown* rejection is retried. A server action that returns a
* value — including an application-level `{ error }` ("Invalid email or
* password", a rate-limit message) — is a real result and passes
* straight through untouched. A lost-on-a-stale-socket POST never
* reached the server, so retrying it once is safe.
*/
export async function retryOnTransportError<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch {
return await fn();
}
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { retryOnTransportError } from '@/lib/retryAction';
describe('retryOnTransportError', () => {
it('returns the result without retrying when the call succeeds', async () => {
const fn = vi.fn().mockResolvedValue({ success: true });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ success: true });
expect(fn).toHaveBeenCalledTimes(1);
});
it('passes through an application-level { error } without retrying', async () => {
// A returned error (bad password, rate limit) is a real result, not a
// transport failure — it must not trigger a retry.
const fn = vi.fn().mockResolvedValue({ error: 'Invalid email or password' });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ error: 'Invalid email or password' });
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries once on a thrown transport error and returns the second result', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new TypeError('The network connection was lost'))
.mockResolvedValueOnce({ success: true });
const result = await retryOnTransportError(fn);
expect(result).toEqual({ success: true });
expect(fn).toHaveBeenCalledTimes(2);
});
it('rejects after a single retry when both attempts throw', async () => {
const fn = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
await expect(retryOnTransportError(fn)).rejects.toThrow('Failed to fetch');
expect(fn).toHaveBeenCalledTimes(2);
});
});
+7 -1
View File
@@ -16,6 +16,7 @@ import { v_1_1_0_7 } from './v1.1.0.7'
import { v_1_1_0_8 } from './v1.1.0.8' import { v_1_1_0_8 } from './v1.1.0.8'
import { v_1_1_0_9 } from './v1.1.0.9' import { v_1_1_0_9 } from './v1.1.0.9'
import { v_1_2_0_1 } from './v1.2.0.1' import { v_1_2_0_1 } from './v1.2.0.1'
import { v_1_2_0_2 } from './v1.2.0.2'
/** /**
* Version graph for the `proof-of-work` package. * Version graph for the `proof-of-work` package.
@@ -61,9 +62,13 @@ import { v_1_2_0_1 } from './v1.2.0.1'
* v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade. Closes the Next * v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade. Closes the Next
* framework RSC + middleware-bypass CVEs; async-params migration * framework RSC + middleware-bypass CVEs; async-params migration
* across all [id] routes + server pages. No schema/data change. * across all [id] routes + server pages. No schema/data change.
* v1.2.0:2 — Login/signup first-tap retry: iOS Safari drops the first
* server-action POST on a stale keep-alive socket
* (NSURLErrorNetworkConnectionLost); retry once on transport
* failure. Client-only, no schema/data change.
*/ */
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_1_2_0_1, current: v_1_2_0_2,
other: [ other: [
v_1_0_0_1, v_1_0_0_1,
v_1_0_0_2, v_1_0_0_2,
@@ -81,5 +86,6 @@ export const versionGraph = VersionGraph.of({
v_1_1_0_7, v_1_1_0_7,
v_1_1_0_8, v_1_1_0_8,
v_1_1_0_9, v_1_1_0_9,
v_1_2_0_1,
], ],
}) })
+32
View File
@@ -0,0 +1,32 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:2 — Login/signup first-tap retry on Safari (2026-06-15).
*
* Fixes a long-standing "first Sign In fails with 'An unexpected error
* occurred', second works" report from iOS Safari. The error string is
* the client-side catch in LoginForm/SignupForm — i.e. the server-action
* POST itself rejected at the transport layer, not any login-logic path
* (those return a clean { error }). Cause: iOS Safari reuses a keep-alive
* socket the server closed while the form sat idle during typing, so the
* first POST dies instantly with NSURLErrorNetworkConnectionLost ("The
* network connection was lost"); a retry lands on a fresh connection.
*
* Fix is client-only: a shared retryOnTransportError() helper retries the
* action ONCE when the call throws (a returned { error } is a real result
* and passes straight through). A stale-socket POST never reached the
* server, so the retry is safe.
*
* App-code only — no schema, no API contract change, no data migration.
*/
export const v_1_2_0_2 = VersionInfo.of({
version: '1.2.0:2',
releaseNotes: {
en_US:
'Fixes the occasional "An unexpected error occurred" on the first Sign In / Create account tap (most common in Safari on iPhone/iPad) — the form now retries automatically, so logging in works on the first try. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})