Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions _bmad-output/implementation-artifacts/deferred-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,3 +735,10 @@ origin: code review of spec-dw-85-ipc-worker-budget.md (blind hunter), 2026-06-1
location: src-tauri/src/ipc/socket_server.rs (unblock_conn, #[cfg(windows)] branch)
reason: DW-85's reaper unblocks a worker stalled in `read_frame` via `libc::shutdown(fd, SHUT_RDWR)` on Unix (empirically confirmed — `int_dw85_watchdog_reclaims_stalled_slots` passes on Linux) and `CancelIoEx(handle, None)` on Windows. The Windows path is `#[cfg(windows)]` and was NOT compiled or run on the Linux dev/CI host, so it is unverified that `CancelIoEx` actually cancels the worker thread's pending synchronous `ReadFile` on an `interprocess` 2.4.2 named-pipe handle (cancellation semantics differ for handles opened without `FILE_FLAG_OVERLAPPED`; `CancelIoEx` is cross-thread, unlike `CancelIo`, which is the reason it was chosen). Also confirm the `RawHandle as *mut _ → HANDLE` cast and the two-arg `CancelIoEx` signature compile under `windows = 0.61`. If `CancelIoEx` does not unblock the read, the budget cap still bounds concurrency on Windows but stalled half-open slots are never reclaimed there (degrades to budget-only on Windows). Needs a Windows CI job or manual Windows run to validate; pairs with the broader cross-platform IPC QA (RISK-E6-007).
status: open

### DW-95: Modern WebKitWebDriver resets the test app's env, preventing per-run IPC socket isolation in local E2E

origin: local verification of epic-6 retro item-1 (E2E), 2026-06-16
location: e2e/run.mjs (cliLiveSyncTests, waitForAppSocket / realInstancePresent guard)
reason: The CLI live-sync suite originally pinned a unique per-run `NOTEY_SOCKET_PATH` so the test app and CLI rendezvous on their own endpoint, isolated from any real notey instance. This relies on the env var propagating to the tauri-driver-launched app. It holds on CI's older Ubuntu `webkit2gtk-driver`, but a modern WebKitWebDriver (verified locally on `webkit2gtk-4.1 2.52.4`) **resets the launched app's environment** — stripping `NOTEY_SOCKET_PATH` and forcing `XDG_RUNTIME_DIR` back to the real session value — and `tauri-driver 2.0.6` forwards only `{binary, args}` (no env passthrough). So locally the app always binds the default `$XDG_RUNTIME_DIR/notey.sock`, diverging from the per-run path the CLI used → `notey add` failed exit-2 ("not running") and the suite was 14/16 locally while green on CI. Fixed test-only by discovering the socket the app actually bound (probe configured path + `$XDG_RUNTIME_DIR/notey.sock` for liveness) and pointing the CLI there — local now 16/16, CI unchanged (it matches the first candidate). Residual limitation: because the test app falls back to the *default* socket locally, the suite cannot isolate from a real running notey; it now detects a live pre-existing instance and skips the live-sync suite with an actionable message rather than hijacking it. Proper isolation would need either an env-passthrough in a newer tauri-driver or an app-level `--socket-path` arg routed via `tauri:options.args` (app source change; deferred). Test-harness robustness, not a product defect.
status: open
4 changes: 2 additions & 2 deletions _bmad-output/implementation-artifacts/sprint-status.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)

generated: 2026-04-03
last_updated: 2026-06-15 # epic-6-retro item 5 (DW-85 IPC worker budget + watchdog) → done (spec-dw-85-ipc-worker-budget.md); items 1 (E2E) + 2 (singleflight) previously → done
last_updated: 2026-06-16 # epic-6-retro item-1 (E2E) note corrected + local live-sync E2E fixed (DW-95; 16/16 local); item 5 (DW-85) → done; items 1+2 previously → done
# Note: epic-2-retrospective rewritten fresh on 2026-04-04
project: notey
project_key: NOKEY
Expand Down Expand Up @@ -191,7 +191,7 @@ development_status:
epic-5-retro-item-4-permission-toml-auto-gen-check: done # MOOT at Epic 6 retro — Epic 6 added no new Tauri commands (IPC delegates to existing services), gotcha did not recur

# Epic 6 Retro Items (2026-06-14):
epic-6-retro-item-1-first-feature-e2e: done # HIGH — trash-lifecycle feature E2E GENUINELY GREEN ON CI: run 27527258028 (HEAD 5be24c7), Linux job success incl. "Build debug binary (Linux)" AND "E2E tests (Linux)". The 2.11 stack alignment fixed the prior "Build debug binary (Linux)" failure; CI's WebKit software-rendering env (WEBKIT_DISABLE_COMPOSITING_MODE/DMABUF + LIBGL_ALWAYS_SOFTWARE) runs the suite that crashes under local xvfb. spec-6-retro-1-first-feature-e2e.md status done. CLI live-sync + export-dialog E2E deferred (DW-91/DW-92 logged). Pinkyd-owned.
epic-6-retro-item-1-first-feature-e2e: done # HIGH — feature E2E green on CI at HEAD 3a76bac (run 27566817463, Linux job): 16/16 incl. trash-lifecycle (P1-E2E-002) AND CLI live-sync (P1-E2E-003). [Corrected 2026-06-16: the earlier note cited HEAD 5be24c7 as the green run, but that commit's CI is FAILURE and predates the live-sync suite (added in 322e570); and the live-sync suite shipped — it was never "deferred". DW-91/DW-92 are trash-suite hygiene follow-ups, not a live-sync deferral.] Specs spec-6-retro-1-first-feature-e2e.md + spec-cli-live-sync-e2e.md status done. Local verification 2026-06-16: 16/16 under xvfb after fixing a modern-WebKitWebDriver socket-rendezvous bug (env-reset; see DW-95) — the suite no longer "crashes under local xvfb". Export-dialog E2E remains the one unbuilt path. Pinkyd-owned.
epic-6-retro-item-2-singleflight-guard-helper: done # HIGH — shared singleflight helper extracted (src/lib/singleflight.ts) + all 7 hand-rolled guards migrated (realtimeSync refreshInFlight/refreshQueued, command-palette isCreatingNote/isTrashingNote/isTogglingTheme/isTogglingLayoutMode, export isExporting); spec-singleflight-guard-helper.md status done, merged b6a0c06/86f2ff4. Verified 2026-06-15: acceptance grep clean (zero legacy-guard hits outside singleflight.ts), tsc clean, 572 FE tests pass. Absorbs epic-5-retro-item-2. Pinkyd-owned.
epic-6-retro-item-3-per-story-dw-logging: backlog # MEDIUM — log every real out-of-scope review dismissal to deferred-work.md PER STORY as part of the coding-assistant workflow (not swept at retro)
epic-6-retro-item-4-close-out-tracking-discipline: done # MEDIUM — reconcile status on close-out not at next retro; Epic 5 items 1/2/4 reconciled this retro
Expand Down
90 changes: 81 additions & 9 deletions e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { spawn } from 'child_process';
import path from 'path';
import os from 'os';
import fs from 'fs';
import net from 'node:net';
import { fileURLToPath } from 'url';

// Force WebKitGTK software rendering before anything spawns the driver. Under a
Expand Down Expand Up @@ -68,6 +69,9 @@ let tauriDriver;
let sessionId;
let passed = 0;
let failed = 0;
// Set once, before any test app launches: whether a real notey instance already
// owns the default IPC socket (see the live-sync suite's guard for why it matters).
let realInstancePresent = false;

async function test(name, fn) {
try {
Expand Down Expand Up @@ -260,19 +264,61 @@ async function waitForMarkerInNoteList(marker, timeoutMs = 5000) {
}

/**
* Poll until the app's IPC socket file exists. The app's IPC server binds during
* Tauri's setup hook, which can lag a freshly-created WebDriver session on a cold
* CI runner — without this gate the first `notey add` can race the bind and fail
* exit-2 ("not running") as a flake. Best-effort: returns after the deadline
* regardless, leaving the CLI's own connect timeout to report a genuine absence.
* Paths the app's IPC server may have bound, in priority order. The app resolves
* its socket as NOTEY_SOCKET_PATH → `$XDG_RUNTIME_DIR/notey.sock` → temp fallback
* (mirrors `socket_server::socket_path`). We export a unique per-run
* NOTEY_SOCKET_PATH, but a modern WebKitWebDriver (webkit2gtk ≥2.52) resets the
* launched app's environment — stripping NOTEY_SOCKET_PATH and forcing
* XDG_RUNTIME_DIR back to the real session value — and tauri-driver has no env
* passthrough, so the harness cannot steer the app's path: it must discover it.
* On CI's older driver the env survives, so the first candidate wins and behavior
* is unchanged.
*/
async function waitForSocket(timeoutMs = 5000) {
const sock = process.env.NOTEY_SOCKET_PATH;
function appSocketCandidates() {
const xdg = process.env.XDG_RUNTIME_DIR;
return [process.env.NOTEY_SOCKET_PATH, xdg ? path.join(xdg, 'notey.sock') : null].filter(Boolean);
}

/**
* Whether a Unix socket path is *accepting connections* (a live server), as
* opposed to a leftover stale socket file. A probe connect that immediately
* disconnects is harmless: the IPC worker sees EOF before any frame and exits.
*/
function isSocketLive(p, timeoutMs = 500) {
return new Promise((resolve) => {
const sock = net.connect(p);
let settled = false;
const timer = setTimeout(() => done(false), timeoutMs);
function done(live) {
if (settled) return;
settled = true;
clearTimeout(timer);
sock.destroy();
resolve(live);
}
sock.once('connect', () => done(true));
sock.once('error', () => done(false));
});
}

/**
* Poll until one of the app's candidate IPC sockets is *live*, and return that
* path. The app's IPC server binds during Tauri's setup hook, which can lag a
* freshly-created WebDriver session on a cold runner — without this gate the first
* `notey add` races the bind and fails exit-2 ("not running"). Liveness (not mere
* file existence) is checked so a leftover stale socket never wins over the path
* the app actually bound. Returns null after the deadline, leaving the CLI's own
* connect timeout to report a genuine absence.
*/
async function waitForAppSocket(timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (sock && fs.existsSync(sock)) return;
for (const c of appSocketCandidates()) {
if (fs.existsSync(c) && (await isSocketLive(c))) return c;
}
await pause(150);
}
return null;
}

// --- Setup ---
Expand Down Expand Up @@ -488,6 +534,20 @@ async function purgeCliNote(marker) {
async function cliLiveSyncTests() {
console.log('\nP1-E2E-003: CLI Live Sync');

// A modern WebKitWebDriver resets the test app's env, forcing it onto the
// default `$XDG_RUNTIME_DIR/notey.sock` — the same endpoint a real desktop notey
// uses. If a real instance was already listening there before we launched, the
// CLI would talk to IT (polluting the real DB) instead of the test app. Skip
// rather than hijack; CI never trips this (no real instance, env survives).
if (realInstancePresent) {
console.log(
' ⚠ skipped: a running notey instance owns the default IPC socket. ' +
'Close it to run the live-sync suite locally — this WebKitWebDriver build ' +
'resets the app env, preventing per-run socket isolation.',
);
return;
}

// Unique per run so the marker note is identifiable in a non-isolated dev DB.
const marker = `E2E-CLISYNC-${Date.now()}`;
let addSucceeded = false;
Expand All @@ -502,7 +562,12 @@ async function cliLiveSyncTests() {
});

await test('notey add (CLI, separate process) exits 0', async () => {
await waitForSocket(); // let the app's IPC server finish binding before connecting
// Discover where the app actually bound (its env may not survive the
// WebDriver launch) and point the CLI at that exact socket. The CLI reads
// NOTEY_SOCKET_PATH at spawn, so updating it here steers `runCli` below.
const appSocket = await waitForAppSocket();
assert(appSocket, `app IPC socket never came up; tried: ${appSocketCandidates().join(', ')}`);
process.env.NOTEY_SOCKET_PATH = appSocket;
const res = await runCli(['add', marker]);
assert(res.code === 0, `CLI exited ${res.code}; stderr: "${res.stderr.trim()}"`);
addSucceeded = true;
Expand All @@ -525,6 +590,13 @@ async function cliLiveSyncTests() {
// --- Main ---

async function main() {
// Detect a real notey instance on the default socket BEFORE launching the test
// app (which, under a modern WebKitWebDriver, binds that same default path). Once
// the test app is up we can't tell its socket apart from a pre-existing one.
const xdg = process.env.XDG_RUNTIME_DIR;
const defaultSock = xdg ? path.join(xdg, 'notey.sock') : null;
realInstancePresent = !!(defaultSock && fs.existsSync(defaultSock) && (await isSocketLive(defaultSock)));

console.log('Starting tauri-driver...');
await startDriver();

Expand Down