Skip to content

Commit b53bbcb

Browse files
Merge pull request #238 from RtlZeroMemory/fix/233-default-q-quit
fix(core): stop app on unhandled q and ctrl+c
2 parents 7d9d682 + 3113b0a commit b53bbcb

File tree

6 files changed

+231
-10
lines changed

6 files changed

+231
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on Keep a Changelog and the project follows Semantic Version
88

99
### Bug Fixes
1010

11+
- **core/runtime**: Unhandled top-level `q`/`Q` and `Ctrl+C` inputs now stop the app by default, while preserving explicit keybinding handlers.
1112
- **native/detect**: Startup terminal probing now exits after a short DA1 drain window instead of waiting the full 500ms budget when XTVERSION never responds, reducing first-render delay on VTE-like terminals.
1213
- **core/constraints**: Constraint input signatures now include all required runtime dependencies, preventing stale cache reuse when unconstrained referenced widget geometry changes.
1314
- **core/layout**: Constraint resolution now performs bounded in-frame settle passes for deeper parent-dependent chains, eliminating first-frame/resize layout jump artifacts in nested constraint trees.

packages/core/src/__tests__/stress/fuzz.random-events.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,17 @@ import {
1818
ZR_KEY_RIGHT,
1919
ZR_KEY_TAB,
2020
ZR_KEY_UP,
21+
ZR_MOD_ALT,
22+
ZR_MOD_CTRL,
23+
ZR_MOD_META,
2124
} from "../../keybindings/keyCodes.js";
2225
import { ui } from "../../widgets/ui.js";
2326

2427
const ITERATIONS = 1024;
28+
const KEY_C = 67;
29+
const KEY_Q = 81;
30+
const TEXT_Q = 81;
31+
const TEXT_LOWER_Q = 113;
2532

2633
const LETTER_KEYS = Object.freeze(Array.from({ length: 26 }, (_unused, idx) => 65 + idx));
2734
const DIGIT_KEYS = Object.freeze(Array.from({ length: 10 }, (_unused, idx) => 48 + idx));
@@ -276,6 +283,23 @@ function randomMods(rng: Rng): number {
276283
return rng.u32() & 0x0f;
277284
}
278285

286+
function isDefaultQuitKeyEvent(
287+
key: number,
288+
mods: number,
289+
action: "down" | "up" | "repeat",
290+
): boolean {
291+
if (action !== "down") return false;
292+
if (key === KEY_Q) {
293+
return (mods & (ZR_MOD_CTRL | ZR_MOD_ALT | ZR_MOD_META)) === 0;
294+
}
295+
if (key === KEY_C) {
296+
const hasCtrl = (mods & ZR_MOD_CTRL) !== 0;
297+
const hasAltOrMeta = (mods & (ZR_MOD_ALT | ZR_MOD_META)) !== 0;
298+
return hasCtrl && !hasAltOrMeta;
299+
}
300+
return false;
301+
}
302+
279303
function randomKeyCode(rng: Rng, profile: EventProfile): number {
280304
if (profile.interactiveKeyBias && chance(rng, 65)) {
281305
return pick(rng, NAV_KEYS);
@@ -307,11 +331,17 @@ function generateKeyEvent(rng: Rng, stream: StreamState, profile: EventProfile):
307331
}
308332
}
309333

334+
let mods = randomMods(rng);
335+
// Keep fuzz deterministic while avoiding global default quit hotkeys.
336+
if (isDefaultQuitKeyEvent(key, mods, action)) {
337+
mods |= ZR_MOD_ALT;
338+
}
339+
310340
return {
311341
kind: "key",
312342
timeMs: nextTime(stream, rng),
313343
key,
314-
mods: randomMods(rng),
344+
mods,
315345
action,
316346
};
317347
}
@@ -376,10 +406,14 @@ function generateMouseEvent(
376406
}
377407

378408
function generateTextEvent(rng: Rng, stream: StreamState): EncodedInputEvent {
409+
let codepoint = 32 + (rng.u32() % 95);
410+
if (codepoint === TEXT_Q || codepoint === TEXT_LOWER_Q) {
411+
codepoint = (codepoint === TEXT_Q ? TEXT_Q + 1 : TEXT_LOWER_Q - 1) >>> 0;
412+
}
379413
return {
380414
kind: "text",
381415
timeMs: nextTime(stream, rng),
382-
codepoint: 32 + (rng.u32() % 95),
416+
codepoint,
383417
};
384418
}
385419

packages/core/src/app/__tests__/resilience.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { assert, test } from "@rezi-ui/testkit";
22
import { parseInternedStrings } from "../../__tests__/drawlistDecode.js";
33
import { defineWidget, ui } from "../../index.js";
4+
import { ZR_MOD_CTRL, ZR_MOD_SHIFT } from "../../keybindings/keyCodes.js";
45
import { createApp } from "../createApp.js";
56
import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js";
67
import { StubBackend } from "./stubBackend.js";
@@ -161,6 +162,132 @@ test("top-level view error screen handles Q by stopping and disposing the app",
161162
assert.equal(backend.disposeCalls >= 1, true);
162163
});
163164

165+
test("unhandled q text event stops app by default", async () => {
166+
const backend = new StubBackend();
167+
const app = createApp({ backend, initialState: {} });
168+
169+
app.view(() => ui.text("hello"));
170+
171+
await app.start();
172+
await emitResize(backend, 1);
173+
await settleNextFrame(backend);
174+
175+
await pushEvents(backend, [{ kind: "text", timeMs: 3, codepoint: 113 }]);
176+
await flushMicrotasks(30);
177+
178+
assert.equal(backend.stopCalls >= 1, true);
179+
assert.equal(backend.disposeCalls, 0);
180+
});
181+
182+
test("unhandled ctrl+c key/text events stop app by default", async () => {
183+
const backend = new StubBackend();
184+
const app = createApp({ backend, initialState: {} });
185+
186+
app.view(() => ui.text("hello"));
187+
188+
await app.start();
189+
await emitResize(backend, 1);
190+
await settleNextFrame(backend);
191+
192+
await pushEvents(backend, [
193+
{ kind: "key", timeMs: 3, key: 67, mods: ZR_MOD_CTRL, action: "down" },
194+
]);
195+
await flushMicrotasks(30);
196+
197+
assert.equal(backend.stopCalls >= 1, true);
198+
199+
await app.start();
200+
await emitResize(backend, 5);
201+
await settleNextFrame(backend);
202+
203+
await pushEvents(backend, [{ kind: "text", timeMs: 6, codepoint: 3 }]);
204+
await flushMicrotasks(30);
205+
206+
assert.equal(backend.stopCalls >= 2, true);
207+
});
208+
209+
test("handled ctrl+c copy in input does not trigger default quit", async () => {
210+
const backend = new StubBackend();
211+
const app = createApp({ backend, initialState: {} });
212+
213+
app.view(() =>
214+
ui.input({
215+
id: "inp",
216+
value: "hello world",
217+
}),
218+
);
219+
220+
await app.start();
221+
await emitResize(backend, 1);
222+
await settleNextFrame(backend);
223+
224+
await pushEvents(backend, [
225+
{ kind: "key", timeMs: 2, key: 3, mods: 0, action: "down" }, // Tab -> focus input
226+
{ kind: "key", timeMs: 3, key: 22, mods: ZR_MOD_SHIFT | ZR_MOD_CTRL, action: "down" }, // Shift+Ctrl+Left
227+
{ kind: "key", timeMs: 4, key: 67, mods: ZR_MOD_CTRL, action: "down" }, // Ctrl+C
228+
]);
229+
230+
assert.equal(backend.stopCalls, 0);
231+
assert.equal(backend.disposeCalls, 0);
232+
});
233+
234+
test("unhandled quit emits fatal when stop throws synchronously", async () => {
235+
class ThrowingStopBackend extends StubBackend {
236+
override stop(): Promise<void> {
237+
this.stopCalls++;
238+
this.callLog.push("stop");
239+
throw new Error("sync-stop-fail");
240+
}
241+
}
242+
243+
const backend = new ThrowingStopBackend();
244+
const app = createApp({ backend, initialState: {} });
245+
const fatals: string[] = [];
246+
247+
app.view(() => ui.text("hello"));
248+
app.onEvent((ev) => {
249+
if (ev.kind === "fatal") fatals.push(`${ev.code}:${ev.detail}`);
250+
});
251+
252+
await app.start();
253+
await emitResize(backend, 1);
254+
await settleNextFrame(backend);
255+
256+
await pushEvents(backend, [{ kind: "text", timeMs: 3, codepoint: 113 }]);
257+
await flushMicrotasks(30);
258+
259+
assert.equal(fatals.length >= 1, true);
260+
assert.equal(
261+
fatals[0]?.startsWith("ZRUI_BACKEND_ERROR:stop threw after unhandled quit input:"),
262+
true,
263+
);
264+
assert.equal(backend.disposeCalls, 1);
265+
assert.equal(backend.stopCalls >= 2, true);
266+
});
267+
268+
test("custom q keybinding overrides default unhandled quit behavior", async () => {
269+
const backend = new StubBackend();
270+
const app = createApp({ backend, initialState: {} });
271+
let keybindingHits = 0;
272+
273+
app.view(() => ui.text("hello"));
274+
app.keys({
275+
q: () => {
276+
keybindingHits++;
277+
},
278+
});
279+
280+
await app.start();
281+
await emitResize(backend, 1);
282+
await settleNextFrame(backend);
283+
284+
await pushEvents(backend, [{ kind: "text", timeMs: 3, codepoint: 113 }]);
285+
await flushMicrotasks(30);
286+
287+
assert.equal(keybindingHits, 1);
288+
assert.equal(backend.stopCalls, 0);
289+
});
290+
164291
test("app.run() wires SIGINT/SIGTERM/SIGHUP and performs graceful shutdown", async () => {
165292
const backend = new StubBackend();
166293
const app = createApp({ backend, initialState: 0 });

packages/core/src/app/createApp.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ type TopLevelViewError = Readonly<{
366366

367367
const KEY_Q = 81;
368368
const KEY_R = 82;
369+
const KEY_C = 67;
370+
const KEY_LOWER_Q = 113;
371+
const KEY_LOWER_R = 114;
372+
const CTRL_C_CODEPOINT = 3;
369373

370374
function captureTopLevelViewError(value: unknown): TopLevelViewError {
371375
if (value instanceof Error) {
@@ -420,7 +424,7 @@ function isTopLevelRetryEvent(ev: ZrevEvent): boolean {
420424
return ev.action === "down" && isUnmodifiedLetterKey(ev.mods) && ev.key === KEY_R;
421425
}
422426
if (ev.kind === "text") {
423-
return ev.codepoint === KEY_R || ev.codepoint === 114;
427+
return ev.codepoint === KEY_R || ev.codepoint === KEY_LOWER_R;
424428
}
425429
return false;
426430
}
@@ -430,11 +434,27 @@ function isTopLevelQuitEvent(ev: ZrevEvent): boolean {
430434
return ev.action === "down" && isUnmodifiedLetterKey(ev.mods) && ev.key === KEY_Q;
431435
}
432436
if (ev.kind === "text") {
433-
return ev.codepoint === KEY_Q || ev.codepoint === 113;
437+
return ev.codepoint === KEY_Q || ev.codepoint === KEY_LOWER_Q;
434438
}
435439
return false;
436440
}
437441

442+
function isUnmodifiedTextQuitEvent(ev: ZrevEvent): boolean {
443+
if (ev.kind !== "text") return false;
444+
return (
445+
ev.codepoint === KEY_Q || ev.codepoint === KEY_LOWER_Q || ev.codepoint === CTRL_C_CODEPOINT
446+
);
447+
}
448+
449+
function isUnhandledCtrlCKeyEvent(ev: ZrevEvent): boolean {
450+
if (ev.kind !== "key") return false;
451+
if (ev.action !== "down") return false;
452+
if (ev.key !== KEY_C) return false;
453+
const hasCtrl = (ev.mods & ZR_MOD_CTRL) !== 0;
454+
if (!hasCtrl) return false;
455+
return (ev.mods & (ZR_MOD_ALT | ZR_MOD_META)) === 0;
456+
}
457+
438458
type ProcessLike = Readonly<{
439459
on?: ((event: string, handler: (...args: unknown[]) => void) => unknown) | undefined;
440460
off?: ((event: string, handler: (...args: unknown[]) => void) => unknown) | undefined;
@@ -940,6 +960,27 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
940960
});
941961
}
942962

963+
function stopFromUnhandledQuitEvent(): void {
964+
let stopPromise: Promise<void>;
965+
try {
966+
stopPromise = app.stop();
967+
} catch (e: unknown) {
968+
// Late events can race while a stop is already in-flight; avoid double-fatal.
969+
if (lifecycleBusy === "stop") return;
970+
enqueueFatal(
971+
"ZRUI_BACKEND_ERROR",
972+
`stop threw after unhandled quit input: ${describeThrown(e)}`,
973+
);
974+
return;
975+
}
976+
void stopPromise.catch((e: unknown) => {
977+
enqueueFatal(
978+
"ZRUI_BACKEND_ERROR",
979+
`stop rejected after unhandled quit input: ${describeThrown(e)}`,
980+
);
981+
});
982+
}
983+
943984
function buildRuntimeBreadcrumbSnapshot(renderTimeMs: number): RuntimeBreadcrumbSnapshot | null {
944985
if (!runtimeBreadcrumbsEnabled) return null;
945986
const widgetSnapshot = widgetRenderer.getRuntimeBreadcrumbSnapshot();
@@ -1310,6 +1351,15 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
13101351
emit({ kind: "action", ...routed.action });
13111352
if (sm.state !== "Running") return;
13121353
}
1354+
if (
1355+
routed.action === undefined &&
1356+
!routed.needsRender &&
1357+
routed.consumed !== true &&
1358+
(isUnmodifiedTextQuitEvent(ev) || isUnhandledCtrlCKeyEvent(ev))
1359+
) {
1360+
noteBreadcrumbConsumptionPath("widgetRouting");
1361+
stopFromUnhandledQuitEvent();
1362+
}
13131363
}
13141364
}
13151365
} finally {

packages/core/src/app/widgetRenderer.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ export type WidgetRenderPlan = Readonly<{
406406
export type WidgetRoutingOutcome = Readonly<{
407407
needsRender: boolean;
408408
action?: RoutedAction;
409+
consumed?: boolean;
409410
}>;
410411

411412
/**
@@ -509,6 +510,10 @@ const EMPTY_CONSTRAINT_BREADCRUMBS: RuntimeBreadcrumbConstraintsSummary = Object
509510
});
510511
const ROUTE_RENDER: WidgetRoutingOutcome = Object.freeze({ needsRender: true });
511512
const ROUTE_NO_RENDER: WidgetRoutingOutcome = Object.freeze({ needsRender: false });
513+
const ROUTE_NO_RENDER_CONSUMED: WidgetRoutingOutcome = Object.freeze({
514+
needsRender: false,
515+
consumed: true,
516+
});
512517
const ZERO_RECT: Rect = Object.freeze({ x: 0, y: 0, w: 0, h: 0 });
513518
const INCREMENTAL_DAMAGE_AREA_FRACTION = 0.45;
514519
const DEFAULT_POSITION_TRANSITION_DURATION_MS = 180;
@@ -1896,7 +1901,7 @@ export class WidgetRenderer<S> {
18961901
if (event.kind === "key" && event.action === "down") {
18971902
const shortcutResult = this.routeOverlayShortcut(event);
18981903
if (shortcutResult === "matched") return ROUTE_RENDER;
1899-
if (shortcutResult === "pending") return ROUTE_NO_RENDER;
1904+
if (shortcutResult === "pending") return ROUTE_NO_RENDER_CONSUMED;
19001905

19011906
const topLayerId =
19021907
this.layerStack.length > 0 ? (this.layerStack[this.layerStack.length - 1] ?? null) : null;
@@ -2042,12 +2047,12 @@ export class WidgetRenderer<S> {
20422047

20432048
if (isCut && editor.readOnly !== true) {
20442049
const cut = selection ? deleteRange(editor.lines, selection) : null;
2045-
if (!cut) return ROUTE_NO_RENDER;
2050+
if (!cut) return ROUTE_NO_RENDER_CONSUMED;
20462051
editor.onSelectionChange(null);
20472052
editor.onChange(cut.lines, cut.cursor);
20482053
return ROUTE_RENDER;
20492054
}
2050-
return ROUTE_NO_RENDER;
2055+
return ROUTE_NO_RENDER_CONSUMED;
20512056
}
20522057

20532058
const rect = this.rectById.get(editor.id) ?? null;

packages/core/src/app/widgetRenderer/inputEditing.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { InputMeta } from "../../runtime/widgetMeta.js";
1717
export type InputEditingRoutingOutcome = Readonly<{
1818
needsRender: boolean;
1919
action?: RoutedAction;
20+
consumed?: boolean;
2021
}>;
2122

2223
type RouteInputEditingEventContext = Readonly<{
@@ -32,7 +33,10 @@ type RouteInputEditingEventContext = Readonly<{
3233
}>;
3334

3435
const ROUTE_RENDER: InputEditingRoutingOutcome = Object.freeze({ needsRender: true });
35-
const ROUTE_NO_RENDER: InputEditingRoutingOutcome = Object.freeze({ needsRender: false });
36+
const ROUTE_NO_RENDER_CONSUMED: InputEditingRoutingOutcome = Object.freeze({
37+
needsRender: false,
38+
consumed: true,
39+
});
3640

3741
function invokeOnInputSafely(
3842
meta: InputMeta,
@@ -180,7 +184,7 @@ export function routeInputEditingEvent(
180184
return Object.freeze({ needsRender: true, action });
181185
}
182186
}
183-
return ROUTE_NO_RENDER;
187+
return ROUTE_NO_RENDER_CONSUMED;
184188
}
185189
}
186190

@@ -203,7 +207,7 @@ export function routeInputEditingEvent(
203207
});
204208
return Object.freeze({ needsRender: true, action });
205209
}
206-
return ROUTE_NO_RENDER;
210+
return ROUTE_NO_RENDER_CONSUMED;
207211
}
208212
}
209213

0 commit comments

Comments
 (0)