From 613e7940d7b75167216f2f7878d088ba68c2d76c Mon Sep 17 00:00:00 2001 From: DrumRobot Date: Mon, 27 Jul 2026 17:12:28 +0900 Subject: [PATCH 1/5] fix(core): guard searchProjectContent and listSessions against readdir TOCTOU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project's folder can vanish between listProjects() returning it and a later per-project readdir (cross-PC sync, manual deletion) — the exact TOCTOU window listProjects itself already guards against (Issue #103). searchProjectContent (search.ts) lacked this guard: an ENOENT there would kill the entire searchSessions Effect via Effect.all's fail-fast semantics, losing content-search results from every other project. Flagged as an Internal Code Review finding on PR #163, deferred as out of scope for that PR (only `export` keywords were touched there). listSessions (crud.ts) has the identical unguarded readdir, and is hit even earlier — searchSessions calls it during Phase 1 title search, unconditionally, before Phase 2 content search ever runs. A regression test written against only the search.ts fix caught this: the test still failed with the same class of error, just originating from listSessions instead. Both call sites now mirror listProjects' existing catchAll pattern (narrow to ENOENT/ENOTDIR, propagate other errors like EACCES). Follow-up tracked separately: https://github.com/es6kr/claude-code-sessions/issues/211 (hermetic webview HTTP test fixture migration — the other PR #163 finding) Signed-off-by: DrumRobot --- packages/core/src/session/crud.ts | 20 ++++- packages/core/src/session/search.test.ts | 108 +++++++++++++++++++++++ packages/core/src/session/search.ts | 29 +++++- 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/packages/core/src/session/crud.ts b/packages/core/src/session/crud.ts index 18644a1..b43f512 100644 --- a/packages/core/src/session/crud.ts +++ b/packages/core/src/session/crud.ts @@ -11,6 +11,7 @@ import { fileExists, isContinuationSummary, isMetaMessage, + isMissingFolderError, cleanupSplitFirstMessage, parseJsonlLines, readJsonlFile, @@ -115,7 +116,10 @@ const readSessionMeta = (projectPath: string, file: string, projectName: string) export const listSessions = (projectName: string) => Effect.gen(function* () { const projectPath = path.join(getSessionsDir(), projectName) - const files = yield* Effect.tryPromise(() => fs.readdir(projectPath)) + const files = yield* Effect.tryPromise({ + try: () => fs.readdir(projectPath), + catch: (error) => error as NodeJS.ErrnoException, + }) const sessionFiles = filterSessionFiles(files) const sessions = yield* Effect.all( @@ -131,7 +135,19 @@ export const listSessions = (projectName: string) => ) return sortSessionsByDate(sessions.filter((s): s is NonNullable => s !== null)) - }) + }).pipe( + // Guard against TOCTOU: a project's folder may vanish between listProjects() + // returning it and this readdir (cross-PC sync, manual deletion). Narrow to + // ENOENT/ENOTDIR so unrelated I/O failures (EACCES, EIO, etc.) still propagate. + // Mirrors listProjects' own guard — see Issue #103. + Effect.catchAll((error) => { + if (!isMissingFolderError(error)) { + return Effect.fail(error) + } + log.debug(`listSessions: skipping missing project ${projectName}`, error) + return Effect.succeed([]) + }) + ) // Deduplicate messages by `uuid`, keeping the last occurrence. Messages without // a `uuid` (summary, file-history-snapshot, custom-title, agent-name) pass through diff --git a/packages/core/src/session/search.test.ts b/packages/core/src/session/search.test.ts index b852b81..20754f2 100644 --- a/packages/core/src/session/search.test.ts +++ b/packages/core/src/session/search.test.ts @@ -4,6 +4,16 @@ import * as path from 'node:path' import * as os from 'node:os' import { Effect } from 'effect' +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + const readdirMock = vi.fn(actual.readdir) + return { + ...actual, + readdir: readdirMock, + default: { ...actual, readdir: readdirMock }, + } +}) + vi.mock('../paths.js', async () => { const actual = await vi.importActual('../paths.js') return { @@ -135,6 +145,10 @@ describe('searchSessions', () => { let tempDir: string beforeEach(async () => { + // Restore readdir to the real implementation; individual tests may override. + const actual = await vi.importActual('node:fs/promises') + vi.mocked(fs.readdir).mockImplementation(actual.readdir as typeof fs.readdir) + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'search-test-')) vi.mocked(getSessionsDir).mockReturnValue(tempDir) }) @@ -260,4 +274,98 @@ describe('searchSessions', () => { const results = await Effect.runPromise(searchSessions('needle')) expect(results).toEqual([]) }) + + // searchProjectContent TOCTOU guard — a project's folder may vanish between + // listProjects() returning it and searchProjectContent's own readdir (cross-PC + // sync, manual deletion). Mirrors listProjects' own guard, see Issue #103. + describe('searchProjectContent TOCTOU safety', () => { + function makeEnoent(p: string): NodeJS.ErrnoException { + const err = new Error( + `ENOENT: no such file or directory, scandir '${p}'` + ) as NodeJS.ErrnoException + err.code = 'ENOENT' + return err + } + + it("skips a project whose folder vanishes mid-content-search, keeping other projects' results", async () => { + const projGone = '-Users-test-search-gone' + const projKeep = '-Users-test-search-keep' + await writeSessionWithDistinctTitleAndContent( + projGone, + 'sess-gone', + 'plain title', + 'this has NEEDLE inside' + ) + await writeSessionWithDistinctTitleAndContent( + projKeep, + 'sess-keep', + 'plain title', + 'this also has NEEDLE inside' + ) + + // listProjects() also readdirs each project folder (for sessionCount) before + // searchProjectContent gets a turn. Succeed on that first call so projGone + // survives into targetProjects, then fail with ENOENT on the *next* readdir + // for the same path — simulating the folder vanishing between the two reads + // (the actual TOCTOU window this guard protects, not a same-instant miss). + const actual = await vi.importActual('node:fs/promises') + let goneCallCount = 0 + vi.mocked(fs.readdir).mockImplementation(((p: unknown, opts: unknown) => { + if (typeof p === 'string' && p.endsWith(projGone)) { + goneCallCount += 1 + if (goneCallCount > 1) { + return Promise.reject(makeEnoent(p)) + } + } + return (actual.readdir as typeof fs.readdir)( + p as Parameters[0], + opts as Parameters[1] + ) + }) as typeof fs.readdir) + + // Before fix: the whole searchSessions Effect would die on projGone's ENOENT. + // After fix: projGone is skipped, projKeep's content match still returned. + const results = await Effect.runPromise(searchSessions('needle', { searchContent: true })) + const ids = results.map((r) => r.sessionId) + expect(ids).toContain('sess-keep') + expect(ids).not.toContain('sess-gone') + }) + + it('propagates a non-ENOENT readdir error (EACCES) instead of silently skipping', async () => { + const projDenied = '-Users-test-search-eacces' + await writeSessionWithDistinctTitleAndContent( + projDenied, + 'sess-denied', + 'plain title', + 'this has NEEDLE inside' + ) + + // Same call-count trick as above: let listProjects' own readdir succeed so + // projDenied reaches searchProjectContent, then fail with EACCES specifically + // on searchProjectContent's own readdir — isolating this test to *this* + // guard's non-ENOENT passthrough, not listProjects' pre-existing one. + const actual = await vi.importActual('node:fs/promises') + let deniedCallCount = 0 + vi.mocked(fs.readdir).mockImplementation(((p: unknown, opts: unknown) => { + if (typeof p === 'string' && p.endsWith(projDenied)) { + deniedCallCount += 1 + if (deniedCallCount > 1) { + const err = new Error( + `EACCES: permission denied, scandir '${p}'` + ) as NodeJS.ErrnoException + err.code = 'EACCES' + return Promise.reject(err) + } + } + return (actual.readdir as typeof fs.readdir)( + p as Parameters[0], + opts as Parameters[1] + ) + }) as typeof fs.readdir) + + await expect( + Effect.runPromise(searchSessions('needle', { searchContent: true })) + ).rejects.toThrow() + }) + }) }) diff --git a/packages/core/src/session/search.ts b/packages/core/src/session/search.ts index a0b7bcc..26d1414 100644 --- a/packages/core/src/session/search.ts +++ b/packages/core/src/session/search.ts @@ -5,11 +5,19 @@ import { Effect, pipe } from 'effect' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { getSessionsDir } from '../paths.js' -import { extractTextContent, extractTitle, tryParseJsonLine } from '../utils.js' +import { + extractTextContent, + extractTitle, + isMissingFolderError, + tryParseJsonLine, +} from '../utils.js' +import { createLogger } from '../logger.js' import { listProjects } from './projects.js' import { listSessions } from './crud.js' import type { Message, SearchResult, Project } from '../types.js' +const log = createLogger('search') + // Pure function: extract snippet around match export const extractSnippet = (text: string, matchIndex: number, queryLength: number): string => { const start = Math.max(0, matchIndex - 50) @@ -74,7 +82,10 @@ const searchSessionContent = ( const searchProjectContent = (project: Project, queryLower: string, alreadyFoundIds: Set) => Effect.gen(function* () { const projectPath = path.join(getSessionsDir(), project.name) - const files = yield* Effect.tryPromise(() => fs.readdir(projectPath)) + const files = yield* Effect.tryPromise({ + try: () => fs.readdir(projectPath), + catch: (error) => error as NodeJS.ErrnoException, + }) const sessionFiles = files.filter((f) => f.endsWith('.jsonl') && !f.startsWith('agent-')) // Filter out already found sessions and create search effects @@ -90,7 +101,19 @@ const searchProjectContent = (project: Project, queryLower: string, alreadyFound const results = yield* Effect.all(searchEffects, { concurrency: 10 }) return results.filter((r): r is NonNullable => r !== null) - }) + }).pipe( + // Guard against TOCTOU: a project's folder may vanish between listProjects() + // returning it and this per-project readdir (cross-PC sync, manual deletion). + // Narrow to ENOENT/ENOTDIR so unrelated I/O failures (EACCES, EIO, etc.) still + // propagate. Mirrors listProjects' own guard — see Issue #103. + Effect.catchAll((error) => { + if (!isMissingFolderError(error)) { + return Effect.fail(error) + } + log.debug(`searchProjectContent: skipping missing project ${project.name}`, error) + return Effect.succeed([] as SearchResult[]) + }) + ) // Pattern to detect potential session ID queries (hex chars and hyphens, 8+ chars) const SESSION_ID_PATTERN = /^[a-f0-9][a-f0-9-]{7,}$/i From ee64bfcb8bc78efc26ed1128b26f650d9db1dc4d Mon Sep 17 00:00:00 2001 From: DrumRobot Date: Mon, 27 Jul 2026 17:30:35 +0900 Subject: [PATCH 2/5] fix(core): relocate search TOCTOU guard, fix UnknownException unwrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems found while writing the regression test for the previous commit's fix: 1. Applying the same guard to listSessions() (crud.ts) broke its existing contract: an MCP integration test asserts listSessions('non-existent -project') should throw, since callers can pass an arbitrary/typo'd project name and silently returning [] would hide that bug. The TOCTOU guard only makes sense when a project name came from a just-completed listProjects() enumeration (so it's known to have existed a moment ago) — that context is specific to searchSessions' Phase 1 title search, not to listSessions itself. Reverted crud.ts; the guard now wraps the listSessions(project.name) call site inside search.ts only. 2. isMissingFolderError(error) returned false even for a genuine ENOENT, because listSessions' plain-form Effect.tryPromise(() => fs.readdir(...)) (no explicit `catch` mapping) wraps rejections in Effect's UnknownException, with the real NodeJS.ErrnoException nested under `.error`/`.cause` rather than at the top level. isMissingFolderError now unwraps both, so it works regardless of which tryPromise form the effect being guarded happens to use. Full workspace test suite (core 505, mcp 26, vscode-extension 67, web 102) green after this change. Signed-off-by: DrumRobot --- packages/core/src/session/crud.ts | 20 ++------------------ packages/core/src/session/search.ts | 21 ++++++++++++++++++++- packages/core/src/utils.ts | 12 +++++++++++- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/core/src/session/crud.ts b/packages/core/src/session/crud.ts index b43f512..18644a1 100644 --- a/packages/core/src/session/crud.ts +++ b/packages/core/src/session/crud.ts @@ -11,7 +11,6 @@ import { fileExists, isContinuationSummary, isMetaMessage, - isMissingFolderError, cleanupSplitFirstMessage, parseJsonlLines, readJsonlFile, @@ -116,10 +115,7 @@ const readSessionMeta = (projectPath: string, file: string, projectName: string) export const listSessions = (projectName: string) => Effect.gen(function* () { const projectPath = path.join(getSessionsDir(), projectName) - const files = yield* Effect.tryPromise({ - try: () => fs.readdir(projectPath), - catch: (error) => error as NodeJS.ErrnoException, - }) + const files = yield* Effect.tryPromise(() => fs.readdir(projectPath)) const sessionFiles = filterSessionFiles(files) const sessions = yield* Effect.all( @@ -135,19 +131,7 @@ export const listSessions = (projectName: string) => ) return sortSessionsByDate(sessions.filter((s): s is NonNullable => s !== null)) - }).pipe( - // Guard against TOCTOU: a project's folder may vanish between listProjects() - // returning it and this readdir (cross-PC sync, manual deletion). Narrow to - // ENOENT/ENOTDIR so unrelated I/O failures (EACCES, EIO, etc.) still propagate. - // Mirrors listProjects' own guard — see Issue #103. - Effect.catchAll((error) => { - if (!isMissingFolderError(error)) { - return Effect.fail(error) - } - log.debug(`listSessions: skipping missing project ${projectName}`, error) - return Effect.succeed([]) - }) - ) + }) // Deduplicate messages by `uuid`, keeping the last occurrence. Messages without // a `uuid` (summary, file-history-snapshot, custom-title, agent-name) pass through diff --git a/packages/core/src/session/search.ts b/packages/core/src/session/search.ts index 26d1414..1b58cb4 100644 --- a/packages/core/src/session/search.ts +++ b/packages/core/src/session/search.ts @@ -219,7 +219,26 @@ export const searchSessions = ( timestamp: session.updatedAt, }) satisfies SearchResult ) - ) + ), + // Guard against TOCTOU: `project` just came from listProjects(), but its + // folder may vanish before this per-project listSessions() call (cross-PC + // sync, manual deletion). listSessions() itself intentionally throws on a + // missing project (callers passing an arbitrary/typo'd name should see an + // error — see packages/mcp's "should throw error for non-existent project" + // test), so the guard lives here at the call site, scoped to this + // already-enumerated aggregation context only. Narrow to ENOENT/ENOTDIR so + // unrelated I/O failures (EACCES, EIO, etc.) still propagate. Mirrors + // listProjects' own guard — see Issue #103. + Effect.catchAll((error) => { + if (!isMissingFolderError(error)) { + return Effect.fail(error) + } + log.debug( + `searchSessions: skipping missing project ${project.name} in title search`, + error + ) + return Effect.succeed([] as SearchResult[]) + }) ) ) diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 3931008..fa61cd2 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -33,10 +33,20 @@ export class FileWriteError extends Data.TaggedError('FileWriteError')<{ * (ENOENT, ENOTDIR). Used to narrow TOCTOU-race recovery so unrelated * I/O failures (EACCES, EIO, EMFILE, EBUSY, etc.) still propagate. * + * Unwraps Effect's `UnknownException` (the wrapper `Effect.tryPromise(fn)` + * produces when called without an explicit `{ try, catch }` mapping — the + * original `NodeJS.ErrnoException` ends up on `.error`/`.cause` instead of + * the top level) so callers don't need to know which `tryPromise` form the + * effect they're guarding happens to use. + * * See Issue #103 + .claude/rules/async-io.md. */ export const isMissingFolderError = (error: unknown): boolean => { - const code = (error as NodeJS.ErrnoException | null | undefined)?.code + const codeOf = (e: unknown) => (e as NodeJS.ErrnoException | null | undefined)?.code + const code = + codeOf(error) ?? + codeOf((error as { error?: unknown } | null | undefined)?.error) ?? + codeOf((error as { cause?: unknown } | null | undefined)?.cause) return code === 'ENOENT' || code === 'ENOTDIR' } From 1f503fcd3394a2d4b8840ce4ccd5288f4452ee29 Mon Sep 17 00:00:00 2001 From: Hayoung Jeong Date: Thu, 13 Aug 2026 13:13:04 +0900 Subject: [PATCH 3/5] fix(core): isolate title-search phase from searchProjectContent EACCES test The EACCES test for searchProjectContent's non-ENOENT passthrough guard was failing one readdir call too early: deniedCallCount > 1 let the error hit Phase 1's listSessions() call (call 2 of 3: listProjects sessionCount -> title-search listSessions -> searchProjectContent), so the test actually exercised title-search's own catchAll re-throwing EACCES and aborting Effect.all before Phase 2 was ever entered. searchProjectContent's own EACCES-passthrough guard had zero intentional coverage. - Bump the threshold to deniedCallCount > 2 so the failure lands on searchProjectContent's own readdir call (call 3), isolating the test to that guard specifically. - Add a dedicated "searchSessions title-search TOCTOU safety" test that exercises the call-2 case directly, so the guard doesn't lose its only (accidental) coverage now that the searchProjectContent test no longer passes through it. Addresses the pending CodeRabbit + Internal Code Review findings from PR review (Important: independently reproduced test-isolation gap). Signed-off-by: Hayoung Jeong --- packages/core/src/session/search.test.ts | 59 ++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/core/src/session/search.test.ts b/packages/core/src/session/search.test.ts index 20754f2..3c5b8fc 100644 --- a/packages/core/src/session/search.test.ts +++ b/packages/core/src/session/search.test.ts @@ -340,16 +340,23 @@ describe('searchSessions', () => { 'this has NEEDLE inside' ) - // Same call-count trick as above: let listProjects' own readdir succeed so - // projDenied reaches searchProjectContent, then fail with EACCES specifically - // on searchProjectContent's own readdir — isolating this test to *this* - // guard's non-ENOENT passthrough, not listProjects' pre-existing one. + // Same call-count trick as above, but this project sees *three* readdir + // calls before searchProjectContent's own turn: (1) listProjects' own + // readdir (for sessionCount), (2) Phase 1 title search's listSessions() + // readdir, (3) searchProjectContent's own readdir. Let the first two + // succeed so projDenied reaches Phase 2, then fail with EACCES + // specifically on call 3 — isolating this test to *searchProjectContent's* + // non-ENOENT passthrough. (Failing at call 2 instead would have the + // title-search phase's own catchAll re-throw the EACCES and abort + // Effect.all before Phase 2 is ever entered — see the dedicated + // "searchSessions title-search TOCTOU safety" block below, which + // exercises exactly that call-2 case.) const actual = await vi.importActual('node:fs/promises') let deniedCallCount = 0 vi.mocked(fs.readdir).mockImplementation(((p: unknown, opts: unknown) => { if (typeof p === 'string' && p.endsWith(projDenied)) { deniedCallCount += 1 - if (deniedCallCount > 1) { + if (deniedCallCount > 2) { const err = new Error( `EACCES: permission denied, scandir '${p}'` ) as NodeJS.ErrnoException @@ -368,4 +375,46 @@ describe('searchSessions', () => { ).rejects.toThrow() }) }) + + // Phase 1 (title search) TOCTOU guard — a project's folder may vanish + // between listProjects() returning it and listSessions()'s own readdir. + // listSessions() intentionally throws on a missing project (see the guard's + // comment at its Effect.catchAll call site in search.ts), so non-ENOENT + // errors (EACCES, etc.) must still propagate here too, distinct from the + // ENOENT/ENOTDIR skip case. This coverage was previously only accidental + // (the searchProjectContent EACCES test above used to fail one readdir call + // too early and exercise this path instead of its own) — now isolated into + // its own test so it isn't lost if the call-count fix above ever changes. + describe('searchSessions title-search TOCTOU safety', () => { + it('propagates a non-ENOENT readdir error (EACCES) from the title-search phase', async () => { + const projDenied = '-Users-test-search-title-eacces' + await writeSessionFile(projDenied, 'sess-denied', 'plain title') + + // listProjects() readdirs each project folder once (for sessionCount); + // let that succeed so projDenied reaches Phase 1, then fail with EACCES + // specifically on listSessions' own readdir (call 2) — isolating this + // test to the title-search guard, before Phase 2 (searchProjectContent) + // is ever entered. + const actual = await vi.importActual('node:fs/promises') + let deniedCallCount = 0 + vi.mocked(fs.readdir).mockImplementation(((p: unknown, opts: unknown) => { + if (typeof p === 'string' && p.endsWith(projDenied)) { + deniedCallCount += 1 + if (deniedCallCount > 1) { + const err = new Error( + `EACCES: permission denied, scandir '${p}'` + ) as NodeJS.ErrnoException + err.code = 'EACCES' + return Promise.reject(err) + } + } + return (actual.readdir as typeof fs.readdir)( + p as Parameters[0], + opts as Parameters[1] + ) + }) as typeof fs.readdir) + + await expect(Effect.runPromise(searchSessions('needle'))).rejects.toThrow() + }) + }) }) From b946ce9fab586280a87854a854ec8b1bc606aac6 Mon Sep 17 00:00:00 2001 From: Hayoung Jeong Date: Thu, 13 Aug 2026 14:26:27 +0900 Subject: [PATCH 4/5] ci: fix errexit swallowing diagnostic output in pre-push run_quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit husky runs .husky/pre-push under `sh -e` (errexit). The previous `out=$("$@" 2>&1); status=$?` form let the assignment inherit the wrapped command's failure exit code, so a failing command killed the script on that line before `status=$?` or `echo "$out"` ever ran — every pre-push failure surfaced only as "husky - pre-push script failed (code 1)" with no diagnostic text, regardless of what actually failed inside build/ typecheck/test. Change the assignment to `out=$("$@" 2>&1) || status=$?` so the failing command is no longer the last element of the statement, keeping errexit from firing on that line while still capturing the real exit code. Verified in isolation under `sh -e`: a function that prints diagnostic lines and returns non-zero now has its output correctly echoed by run_quiet, both for a bare `false` (no output) and for output-producing failures. Signed-off-by: Hayoung Jeong --- .husky/pre-push | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.husky/pre-push b/.husky/pre-push index e9c26c1..795493c 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -26,9 +26,15 @@ if [ "$has_code_push" = false ]; then fi # run_quiet: silent on success, full output on failure +# NOTE: husky runs this script under `sh -e` (errexit). `out=$("$@" 2>&1)` alone +# inherits the wrapped command's exit code, so a failing command kills the +# script on that assignment line before `status=$?`/`echo "$out"` ever run — +# swallowing the diagnostic output entirely. `|| status=$?` keeps the failing +# command from being the last element of the statement, so errexit does not +# fire here, while `$?` is still captured accurately. run_quiet() { - out=$("$@" 2>&1) - status=$? + status=0 + out=$("$@" 2>&1) || status=$? if [ $status -ne 0 ]; then echo "$out" fi From e73e7b4857626ed1adcf0a3d2fed461379217088 Mon Sep 17 00:00:00 2001 From: Hayoung Jeong Date: Thu, 13 Aug 2026 15:11:41 +0900 Subject: [PATCH 5/5] test(core): raise timeout for flaky non-existent-project test 'returns null for non-existent project' occasionally exceeds vitest's default 5000ms test timeout on the dynamic re-import used to pick up mocked modules, unrelated to this PR's own changes (observed on unmodified main too). Raise the per-test timeout to 15000ms rather than changing the test's behavior. Signed-off-by: Hayoung Jeong --- packages/core/src/paths.integration.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/paths.integration.test.ts b/packages/core/src/paths.integration.test.ts index bba3ccb..0f036ff 100644 --- a/packages/core/src/paths.integration.test.ts +++ b/packages/core/src/paths.integration.test.ts @@ -18,6 +18,10 @@ describe('getRealPathFromSession (mocked)', () => { vi.clearAllMocks() }) + // Flaky under load: the dynamic re-import below occasionally exceeds vitest's + // default 5000ms test timeout on a cold module cache (observed independent of + // this test's own logic, on unmodified main). Raise the timeout rather than + // changing behavior. it('returns null for non-existent project', async () => { vi.mocked(fs.readdirSync).mockImplementation(() => { throw new Error('ENOENT') @@ -27,7 +31,7 @@ describe('getRealPathFromSession (mocked)', () => { const { getRealPathFromSession } = await import('./paths.js') const result = getRealPathFromSession('-nonexistent-project') expect(result).toBeNull() - }) + }, 15000) it('returns real cwd from session file when cwd matches folder name', async () => { const mockCwd = '/home/user/example.com'