diff --git a/docs/api-reference.md b/docs/api-reference.md index 85349ee3..4bca9768 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -511,8 +511,9 @@ scrollbackRestored: false }`, ownership-scoped in multi-user mode. - `POST /api/v1/reboot-restore/restore` with `{ sessionIds?: string[] }` (omit to restore everything the caller can see) → `{ restored: RestorableSession[], skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing` - (the directory is gone), `workspace-forbidden` (it is outside the caller's - workspace in multi-user mode), `already-live` (the conversation is already + (the directory is gone), `workspace-forbidden` (in multi-user mode it is + outside the workspace of the user the session belongs to, re-checked against + that owner's current grant rather than the caller's), `already-live` (the conversation is already open, typically resumed by hand from the Resume list), `capacity-reached` (the global or per-user session cap), or `rebuild-failed` (the agent would not start, most often a CLI binary missing from the server's PATH). diff --git a/src/web/public/reboot-restore-ui.js b/src/web/public/reboot-restore-ui.js index 1cdc8da8..2a3599dd 100644 --- a/src/web/public/reboot-restore-ui.js +++ b/src/web/public/reboot-restore-ui.js @@ -11,10 +11,12 @@ * Seeded from `GET /api/reboot-restore` on init and again on every SSE reconnect, * because the tab most likely to want this is one that was open across the reboot * and reconnects to a server that came back up with an empty board. Restore posts to - * `POST /api/reboot-restore/restore`, Dismiss posts to - * `POST /api/reboot-restore/dismiss`, and either way the banner goes away. The - * restored sessions arrive as ordinary `session:created` events, so no extra - * rendering is needed here. + * `POST /api/reboot-restore/restore` and Dismiss posts to + * `POST /api/reboot-restore/dismiss`. Dismiss always clears the banner; Restore + * re-reads the plan afterwards, because the server puts back anything it could + * not build for a reason that may pass, such as a session limit or an agent that + * would not start. The restored sessions arrive as ordinary `session:created` + * events, so no extra rendering is needed here. * * The banner says that terminal history did not survive, because a restored * session is a new pane: the conversation continues and the scrollback does not. diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b5e4873b..f35bf5cd 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -15277,6 +15277,10 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .reboot-restore-banner-detail { color: rgba(255, 255, 255, 0.8); font-weight: 500; + /* A flex item will not shrink below its content width at the default + `min-width: auto`, so without this the session names push the buttons out of + the line between the phone breakpoint and full width. */ + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/web/reboot-restore-registry.ts b/src/web/reboot-restore-registry.ts index 28b4a1d7..0dfab538 100644 --- a/src/web/reboot-restore-registry.ts +++ b/src/web/reboot-restore-registry.ts @@ -48,12 +48,16 @@ export class RebootRestoreRegistry { /** When the boot pass built the plan, in ms since the epoch. */ private builtAt = 0; /** - * Bumped by anything that invalidates entries a restore is already holding. - * A Dismiss arriving mid-restore must win: without this the route's `finally` - * would put its unspent entries back and resurrect the offer the user just - * cleared, with a fresh 24-hour life. + * Per owner, bumped by anything that invalidates that owner's entries while a + * restore is already holding them. A Dismiss arriving mid-restore must win: + * without this the route's `finally` would put its unspent entries back and + * resurrect the offer the user just cleared, with a fresh 24-hour life. + * + * Keyed by owner rather than global, because `clear()` is ownership-scoped. A + * single counter would let one user's Dismiss discard another user's unspent + * entries, and the plan is in-memory, so those offers would be gone for good. */ - private generation = 0; + private generations = new Map(); /** * Owners with a restore in flight, between its take and its last pane. * Keyed by owner so one user's restore does not turn another user's click into @@ -66,12 +70,17 @@ export class RebootRestoreRegistry { set(entries: readonly RebootRestoreEntry[]): void { this.entries = new Map(entries.map((entry) => [entry.sessionId, entry])); this.builtAt = entries.length > 0 ? Date.now() : 0; - this.generation += 1; + this.bumpAll(); } - /** The current generation, for a caller that will later return entries. */ - currentGeneration(): number { - return this.generation; + /** + * The generations of the owners of `entries`, for a caller that will hand some + * of them back later. Pass the result to {@link restore}. + */ + snapshotGenerations(entries: readonly RebootRestoreEntry[]): Map { + const snapshot = new Map(); + for (const entry of entries) snapshot.set(entry.owner, this.generations.get(entry.owner) ?? 0); + return snapshot; } /** @@ -117,12 +126,20 @@ export class RebootRestoreRegistry { * hand is NOT put back, because that one cannot stop being true, and an entry * the banner keeps re-offering forever is noise only Dismiss can clear. */ - restore(entries: readonly RebootRestoreEntry[], generation?: number): void { - // A dismiss (or a fresh boot plan) since the caller took these entries means - // they are no longer wanted back. - if (generation !== undefined && generation !== this.generation) return; - for (const entry of entries) this.entries.set(entry.sessionId, entry); - if (entries.length > 0 && this.builtAt === 0) this.builtAt = Date.now(); + restore(entries: readonly RebootRestoreEntry[], generations?: ReadonlyMap): void { + let added = 0; + for (const entry of entries) { + // A dismiss (or a fresh boot plan) for THIS entry's owner since the caller + // took it means it is no longer wanted back. Another owner's dismiss is + // none of this entry's business. + if (generations) { + const taken = generations.get(entry.owner); + if (taken !== undefined && taken !== (this.generations.get(entry.owner) ?? 0)) continue; + } + this.entries.set(entry.sessionId, entry); + added += 1; + } + if (added > 0 && this.builtAt === 0) this.builtAt = Date.now(); } /** Drop the entries a viewer can see. Returns how many went. */ @@ -130,8 +147,14 @@ export class RebootRestoreRegistry { const removable = [...this.entries.values()].filter((entry) => canAccess(entry.owner)); for (const entry of removable) this.entries.delete(entry.sessionId); if (this.entries.size === 0) this.builtAt = 0; - // Any restore currently in flight must not put its entries back afterwards. - this.generation += 1; + // A restore in flight for these owners must not put their entries back. The + // in-flight owners are the ones that matter and the ones the plan can no + // longer name: `take()` has already removed their entries, so a dismiss that + // lands mid-restore sees nothing of theirs to remove. The bump is limited to + // owners this caller could see, so it cannot reach anyone else's restore. + const invalidated = new Set(removable.map((entry) => entry.owner)); + for (const owner of this.spending) if (canAccess(owner)) invalidated.add(owner); + for (const owner of invalidated) this.bump(owner); return removable.length; } @@ -155,11 +178,25 @@ export class RebootRestoreRegistry { this.entries.clear(); this.builtAt = 0; this.spending.clear(); - this.generation += 1; + this.generations.clear(); + } + + private bump(owner: string | undefined): void { + this.generations.set(owner, (this.generations.get(owner) ?? 0) + 1); + } + + /** Invalidate every owner's in-flight returns, including owners not yet seen. */ + private bumpAll(): void { + for (const owner of new Set([...this.entries.values()].map((entry) => entry.owner))) this.bump(owner); + for (const owner of [...this.generations.keys()]) this.bump(owner); } private dropIfExpired(): void { if (this.builtAt > 0 && Date.now() - this.builtAt > PLAN_TTL_MS) { + // Bump before clearing, while the owners are still known: a restore that + // took entries just before the expiry must not hand them back afterwards + // and give an expired plan another full day of life. + this.bumpAll(); this.entries.clear(); this.builtAt = 0; } diff --git a/src/web/routes/reboot-restore-routes.ts b/src/web/routes/reboot-restore-routes.ts index 636f2cd5..da742cc4 100644 --- a/src/web/routes/reboot-restore-routes.ts +++ b/src/web/routes/reboot-restore-routes.ts @@ -92,8 +92,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes if (!rebootRestoreRegistry.beginSpending(owner)) { return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, 'A reboot restore is already running')); } - const generation = rebootRestoreRegistry.currentGeneration(); const taken = rebootRestoreRegistry.take(canAccess, body.sessionIds); + const generations = rebootRestoreRegistry.snapshotGenerations(taken); // Entries nothing built a pane for, returned to the plan on every exit path // including a throw. Without this a failure between here and the loop would // spend the offer and rebuild nothing, and the plan cannot be rebuilt. @@ -184,16 +184,20 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes }); await ctx.addSession(session); - await ctx.setupSessionListeners(session); - // Shapes the pane, so it has to land before the CLI process starts. + // Before the listeners, because setupSessionListeners() reads the + // image-watcher flag this phase restores; before the spawn, because the + // custom-model environment and the nice priority shape the process. await ctx.reapplyPersistedSessionState(session, saved, 'before-spawn'); + await ctx.setupSessionListeners(session); await session.startInteractive(); // The session's own history, applied only once the pane exists: on a // failed start these totals would belong to a session that never ran. - // Both halves precede the first persist, because a constructed session - // carries none of this and `toState()` is written wholesale, so - // persisting first would replace the fuller record with the reduced one - // and drop the pin that keeps it from being pruned. + // Both halves precede the route's OWN persist, which matters because a + // constructed session carries none of this and `toState()` is written + // wholesale, so persisting first would replace the fuller record with + // the reduced one and drop the pin that keeps it from being pruned. A + // listener-driven persist can still land inside the debounce window + // while the pane starts; the write below repairs the record. await ctx.reapplyPersistedSessionState(session, saved, 'after-spawn'); ctx.persistSessionState(session); @@ -248,8 +252,9 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes } finally { // Anything that never became a pane goes back on offer, including after a // throw, so a transient failure costs a retry rather than the whole plan. - // Passing the generation makes a Dismiss that landed mid-restore win. - rebootRestoreRegistry.restore([...unspent], generation); + // Passing the generations makes a Dismiss that landed mid-restore win, for + // the owners it actually covered. + rebootRestoreRegistry.restore([...unspent], generations); rebootRestoreRegistry.endSpending(owner); } }); diff --git a/src/web/server.ts b/src/web/server.ts index 474d8cd2..095b094d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2923,8 +2923,9 @@ export class WebServer extends EventEmitter { * Split in two phases because the two halves have opposite timing needs: * * - `before-spawn` shapes the pane itself, so it has to land before the CLI - * process starts. The custom-model selection is an environment injection and - * the nice priority is applied to the spawn. + * process starts, and before `setupSessionListeners()`, which reads the + * image-watcher flag. The custom-model selection is an environment injection + * and the nice priority is applied to the spawn. * - `after-spawn` is the session's own accumulated history. It must NOT land * on a session whose pane failed to start: the totals would then belong to a * session that never ran, and any later cleanup would add them to the @@ -2952,6 +2953,10 @@ export class WebServer extends EventEmitter { if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) { session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue }); } + // `setupSessionListeners()` READS this flag to decide whether to start the + // watcher, so setting it later would leave the session reporting the feature + // as on with nothing watching. + if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled; return; } @@ -2974,7 +2979,6 @@ export class WebServer extends EventEmitter { }); } if (saved.color) session.setColor(saved.color); - if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled; if (saved.flickerFilterEnabled !== undefined) session.flickerFilterEnabled = saved.flickerFilterEnabled; } @@ -2989,33 +2993,61 @@ export class WebServer extends EventEmitter { * WORKING DIRECTORY, which belongs to the workspace rather than to this session * and may hold another live session's pasted images. * - * This undoes only what the failed construction did: the map entry, the tab - * layout slot `registerSessionWithLayout()` took, and any pane the CLI launch - * managed to create before it threw. The persisted record is left exactly as it - * was, so the session stays restorable on the next attempt. + * Everything else `_doCleanupSession()` does, this has to do as well. It is the + * inverse of `registerSessionWithLayout()` plus `setupSessionListeners()`, and + * every registration those two make has to come back out — above all + * `sessionListenerRefs`, whose presence makes `setupSessionListeners()` return + * early. Leaving that entry behind is worse than the leak this function exists + * to prevent: the retry reuses the same session id, wires no listeners at all, + * and the user gets a tab that never shows output. + * + * The persisted record, the lifetime totals, the stored Ralph state and the + * workspace's own files are left exactly as they were, so the session stays + * restorable on the next attempt. */ async discardPartiallyBuiltSession(sessionId: string): Promise { const session = this.sessions.get(sessionId); if (!session) return; this.sessions.delete(sessionId); + + // --- the inverse of setupSessionListeners(), in its order --- + const summaryTracker = this.runSummaryTrackers.get(sessionId); + if (summaryTracker) { + summaryTracker.stop(); + this.runSummaryTrackers.delete(sessionId); + } + // An fs.watch on the workspace (or on @fix_plan.md) that nothing else closes. + session.ralphTracker.stopWatchingFixPlan(); + // An FSWatcher on the workspace, likewise. + imageWatcher.unwatchSession(sessionId); + const listeners = this.sessionListenerRefs.get(sessionId); + if (listeners) { + detachSessionListeners(session, listeners); + this.sessionListenerRefs.delete(sessionId); + } + + // --- the inverse of the construction itself --- this.sse.cleanupSessionBatches(sessionId); this.persistDeb.cancelKey(sessionId); + fileStreamManager.closeSessionStreams(sessionId); + // The per-session custom-model config dir carries the endpoint's API key, and + // `before-spawn` may already have written it. Nothing else would ever remove + // it: the stale sweep only touches state.json. A retry rewrites it. + removeConfigDir(customModelConfigDir(sessionId)); try { session.removeAllListeners(); - await session.stop?.(); + await session.stop(true); } catch (err) { console.warn(`[Server] stopping a partially built session failed: ${getErrorMessage(err)}`); } - try { - await this.mux.killSession(sessionId); - } catch { - // The pane may never have been created; nothing to kill is the normal case. - } try { await this.tabLayouts.sessionsRemoved([{ id: sessionId, owner: session.owner }]); } catch (err) { console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`); } + // Any `session:updated` the half-built session emitted before it failed left a + // tab on every other open board, and the client's handler is an upsert. + this.broadcast(SseEvent.SessionDeleted, { id: sessionId }); } private async restoreMuxSessions(): Promise { diff --git a/test/discard-partially-built-session.test.ts b/test/discard-partially-built-session.test.ts new file mode 100644 index 00000000..7232e969 --- /dev/null +++ b/test/discard-partially-built-session.test.ts @@ -0,0 +1,113 @@ +/** + * `WebServer.discardPartiallyBuiltSession()` against the real server object. + * + * The reboot-restore route calls this when a rebuild registers a session and + * then fails to start its pane. It has to be the exact inverse of + * `registerSessionWithLayout()` plus `setupSessionListeners()`, and it must NOT + * be the user-initiated delete: banking the session's token totals, demoting a + * pinned record or deleting the workspace's files would all be wrong for a + * session that never ran. + * + * These tests drive the real method rather than the route, because the route + * tests run against a mock context whose `discardPartiallyBuiltSession` is a + * one-line stub — an earlier version of this function left four registrations + * behind and every route test still passed. + * + * The retry assertion is the important one. `setupSessionListeners()` returns + * early when `sessionListenerRefs` still holds the session id, so a discard that + * leaves that entry makes the next attempt wire nothing at all, and the user + * gets a tab that never shows output. + */ +import { mkdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { WebServer } from '../src/web/server.js'; +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; + +/** Reach the private collections the discard is responsible for emptying. */ +interface ServerInternals { + sessions: Map; + sessionListenerRefs: Map; + runSummaryTrackers: Map; + registerSessionWithLayout(session: Session): Promise; + setupSessionListeners(session: Session): Promise; + discardPartiallyBuiltSession(sessionId: string): Promise; +} + +const WORKSPACE = join(homedir(), '.codeman-test-discard'); +const SESSION_ID = 'a1b2c3d4e5f60718'; + +let server: WebServer; +let internals: ServerInternals; +let mux: TmuxManager; + +function buildSession(): Session { + return new Session({ + id: SESSION_ID, + workingDir: WORKSPACE, + mode: 'claude', + name: 'rebuilt session', + mux, + useMux: true, + }); +} + +beforeEach(() => { + mkdirSync(WORKSPACE, { recursive: true }); + // Test mode: no port is opened and no CLI is launched. + server = new WebServer(0, false, true); + internals = server as unknown as ServerInternals; + mux = new TmuxManager(); +}); + +afterEach(async () => { + await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {}); + rmSync(WORKSPACE, { recursive: true, force: true }); +}); + +describe('discarding a session whose pane never started', () => { + it('takes the session back out of the server', async () => { + const session = buildSession(); + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + expect(internals.sessions.has(SESSION_ID)).toBe(true); + + await internals.discardPartiallyBuiltSession(SESSION_ID); + expect(internals.sessions.has(SESSION_ID)).toBe(false); + }); + + it('releases the listener registration, so a retry can wire itself again', async () => { + const first = buildSession(); + await internals.registerSessionWithLayout(first); + await internals.setupSessionListeners(first); + expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true); + + await internals.discardPartiallyBuiltSession(SESSION_ID); + expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(false); + + // The retry reuses the id by design. `setupSessionListeners()` returns early + // while the refs are still there, so a session built now would run blind: + // no terminal output, no status updates, no exit broadcast. + const retry = buildSession(); + await internals.registerSessionWithLayout(retry); + await internals.setupSessionListeners(retry); + expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true); + }); + + it('stops the run-summary tracker, whose interval would otherwise keep firing', async () => { + const session = buildSession(); + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(true); + + await internals.discardPartiallyBuiltSession(SESSION_ID); + expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(false); + }); + + it('does nothing at all for a session it never registered', async () => { + await expect(internals.discardPartiallyBuiltSession('never-existed')).resolves.toBeUndefined(); + }); +}); diff --git a/test/mocks/mock-route-context-completeness.test.ts b/test/mocks/mock-route-context-completeness.test.ts new file mode 100644 index 00000000..effb98c8 --- /dev/null +++ b/test/mocks/mock-route-context-completeness.test.ts @@ -0,0 +1,28 @@ +/** + * The mock route context must offer everything the real one does. + * + * Route tests pass their context as `ctx as never`, and `tsconfig.json` includes + * only `src/**`, so no type check ever compares the mock against the ports. A + * port that gained a method left this mock missing it twice; both times the + * route under test threw a TypeError inside its own catch, and the suite + * reported a plausible-looking failure for an unrelated reason. + * + * So the comparison is made at runtime, against `WebServer.createRouteContext()` + * rather than against the port types, which is what keeps it from drifting: the + * server's own context object is the thing route modules are really given. + */ +import { describe, expect, it } from 'vitest'; + +import { WebServer } from '../../src/web/server.js'; +import { createMockRouteContext } from './mock-route-context.js'; + +describe('the mock route context', () => { + it('offers every member the real route context does', () => { + const server = new WebServer(0, false, true); + const real = (server as unknown as { createRouteContext(): Record }).createRouteContext(); + const mock = createMockRouteContext() as unknown as Record; + + const missing = Object.keys(real).filter((key) => !(key in mock)); + expect(missing, `mock-route-context.ts is missing: ${missing.join(', ')}`).toEqual([]); + }); +}); diff --git a/test/routes/reboot-restore-rebuild-failure.test.ts b/test/routes/reboot-restore-rebuild-failure.test.ts index f1a35417..9ee93fb4 100644 --- a/test/routes/reboot-restore-rebuild-failure.test.ts +++ b/test/routes/reboot-restore-rebuild-failure.test.ts @@ -282,18 +282,62 @@ describe('a failure before any entry is considered', () => { }); describe('a dismiss that lands while a restore is running', () => { - it('wins, rather than being undone when the restore hands its entries back', async () => { - const entries = [offerEntry('a')]; - rebootRestoreRegistry.set(entries); - const generation = rebootRestoreRegistry.currentGeneration(); - const taken = rebootRestoreRegistry.take(() => true); - expect(taken).toHaveLength(1); + it('wins, rather than being undone when the route hands its entries back', async () => { + rebootRestoreRegistry.set([offerEntry('a')]); + const ctx = createMockRouteContext({ workspaceHooksEnabled: false }); + // The user clicks Dismiss while the restore is between its take and its + // return. Driven through the ROUTE, so removing the generation argument from + // the route would make this fail. + (ctx.getWorkspaceHooksEnabled as ReturnType).mockImplementation(async () => { + rebootRestoreRegistry.clear(() => true); + throw new Error('settings unreadable'); + }); + const app = await createHarness(ctx); - // The user clears the banner while the restore is still working. - rebootRestoreRegistry.clear(() => true); - // The restore finishes and tries to put its unspent entry back. - rebootRestoreRegistry.restore(taken, generation); + await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} }); + + const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data; + expect(left.sessions).toEqual([]); + await app.close(); + }); + + it('reaches an in-flight restore the dismisser can see, even once its entries are taken', async () => { + const mine = offerEntry('mine', 'alice'); + rebootRestoreRegistry.set([mine]); + expect(rebootRestoreRegistry.beginSpending('alice')).toBe(true); + const generations = rebootRestoreRegistry.snapshotGenerations([mine]); + const taken = rebootRestoreRegistry.take((owner) => owner === 'alice'); + + // The plan is empty now, so a dismiss has nothing of Alice's to remove; the + // invalidation has to come from her claimed flight. + rebootRestoreRegistry.clear((owner) => owner === 'alice'); + rebootRestoreRegistry.restore(taken, generations); + rebootRestoreRegistry.endSpending('alice'); expect(rebootRestoreRegistry.list(() => true)).toEqual([]); }); + + it('does not reach another owner, whose unspent entries still come back', async () => { + const mine = offerEntry('mine', 'alice'); + const theirs = offerEntry('theirs', 'bob'); + rebootRestoreRegistry.set([mine, theirs]); + + // Bob is mid-restore, holding his own entry. The claimed flight is what makes + // this the interesting case: a dismiss can no longer see Bob's entries in the + // plan, so the invalidation has to come from the in-flight set, filtered by + // what the dismissing user may access. + expect(rebootRestoreRegistry.beginSpending('bob')).toBe(true); + const bobsGenerations = rebootRestoreRegistry.snapshotGenerations([theirs]); + const bobsTaken = rebootRestoreRegistry.take((owner) => owner === 'bob'); + expect(bobsTaken.map((e) => e.sessionId)).toEqual(['theirs']); + + // Alice dismisses her own banner meanwhile. + rebootRestoreRegistry.clear((owner) => owner === 'alice'); + + // Bob's restore finishes and hands his entry back. Alice's dismiss covered + // her entries, not his, so his offer survives. + rebootRestoreRegistry.restore(bobsTaken, bobsGenerations); + rebootRestoreRegistry.endSpending('bob'); + expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']); + }); });