Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2c78b83
fix(pty): guard node-pty's unlistened win32 conin socket
y49 Jul 28, 2026
1e502f5
fix(pty): drop the pty handle on kill and on child exit
y49 Jul 28, 2026
e3a3666
test(daemon): wait on conditions, not fixed timeouts, in session-ipc
y49 Jul 28, 2026
00f8819
test(ipc,web,pty): wait on conditions instead of fixed delays
y49 Jul 28, 2026
63346b7
fix(pty): report activity from observed output, not from start time
y49 Jul 28, 2026
2c1375a
test(daemon): condition-based waits in event-broadcast
y49 Jul 28, 2026
ec13922
test(daemon): restore a real interval on the pending-invariant check
y49 Jul 28, 2026
e01d9a9
test(daemon): condition-based waits in bootstrap
y49 Jul 28, 2026
ff034c4
ci: add install-smoke-windows
y49 Jul 28, 2026
1674aa0
ci: make the Windows daemon step able to fail
y49 Jul 28, 2026
225872c
test(pty): skip the silent-child activity test on win32
y49 Jul 28, 2026
bc2d504
test(pty): assert the snapshot frame itself, and drop a needless sleep
y49 Jul 28, 2026
f39d14f
test(pty): wait for the probe socket to close, not a fixed delay
y49 Jul 28, 2026
85150c7
test(pty): wait on the shadow terminal, not on a live client
y49 Jul 28, 2026
6446aa3
docs(pty): link the upstream node-pty issue from the conin guard
y49 Jul 28, 2026
2d896b9
test(daemon): stop implying the continue-grace sleep is the only fixe…
y49 Jul 28, 2026
97e52d7
ci(smoke): resolve pty-ok on arrival, keep 5s as a failure deadline only
y49 Jul 28, 2026
46cfcbd
docs(pty): point the conin-guard comment at the shipped lib/*.js paths
y49 Jul 28, 2026
a5ace36
test(pty): assert guardWindowsConinSocket is wired into SessionHost.s…
y49 Jul 28, 2026
f7c9093
test(pty): rename the misnamed shadow local to internals
y49 Jul 28, 2026
5afc61e
test(pty): drop two more fixed sleeps in the activity-flip tests
y49 Jul 28, 2026
83eb81f
test(pty): keep the real conin guard active while asserting it is wired
y49 Jul 28, 2026
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
42 changes: 42 additions & 0 deletions .github/scripts/smoke-pty.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Loads node-pty from a globally installed tlive and spawns a real ConPTY.
//
// A prebuilt binary that resolves can still fail to load, and one that loads
// can still fail to spawn. Only a real install on a real Windows runner sees
// this; the unit-test job installs from the workspace with a full toolchain.

import { createRequire } from 'node:module';
import { join } from 'node:path';

const globalRoot = process.env.TLIVE_GLOBAL_ROOT;
if (!globalRoot) {
console.error('TLIVE_GLOBAL_ROOT is not set');
process.exit(1);
}

const require = createRequire(import.meta.url);
const ptyPath = require.resolve('node-pty', { paths: [join(globalRoot, 'tlive')] });
const { spawn } = require(ptyPath);

const p = spawn('cmd.exe', ['/c', 'echo pty-ok'], { cols: 80, rows: 24 });
let out = '';
let done = false;

// 5s is a failure deadline, not a fixed wait: resolve as soon as the output
// arrives so a passing run doesn't burn 5s of runner time, while a ConPTY
// that never answers still fails instead of hanging the job.
const deadline = setTimeout(() => {
if (done) return;
done = true;
console.error('pty produced no output; got: ' + JSON.stringify(out));
process.exit(1);
}, 5000);

p.onData((d) => {
out += d;
if (!done && out.includes('pty-ok')) {
done = true;
clearTimeout(deadline);
console.log('pty loads and runs');
process.exit(0);
}
});
71 changes: 71 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,74 @@ jobs:
'
echo "::endgroup::"
done

# Windows counterpart to install-smoke, and the only end-to-end Windows
# coverage that exists. `test (windows-latest)` runs unit tests; nothing else
# touches what a Windows user hits on day one — the node-pty win32 prebuild
# actually loading, a ConPTY actually spawning, %APPDATA% paths, the .cmd shim
# npm writes for the bin, and the named-pipe daemon.
#
# The linux install-smoke earned its place by catching node-pty 1.1.0 shipping
# no linux prebuild, which no unit test could see. Same reasoning here: issue
# #59 was a real Windows daemon crash that spent months filed as test flake
# precisely because nothing exercised the product on Windows.
#
# Single job, no matrix — a matrix renames the check and required contexts are
# matched by exact name (see the note on install-smoke above).
install-smoke-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: npm pack
- name: install the real tarball globally
shell: pwsh
run: |
$tgz = Get-ChildItem -Filter tlive-*.tgz | Select-Object -First 1
npm i -g $tgz.FullName --no-audit --no-fund
- name: tlive resolves through the .cmd shim
shell: pwsh
run: tlive --version
- name: node-pty loads and spawns a ConPTY
shell: pwsh
run: |
# `npm root -g` can in principle emit more than one line, which makes
# PowerShell 7+ treat the captured value as a string[] — .Trim() would
# then enumerate silently instead of throwing, and $env:TLIVE_GLOBAL_ROOT
# would end up joined with a space into a corrupted path. Out-String
# collapses it to a single string first.
$env:TLIVE_GLOBAL_ROOT = (npm root -g | Out-String).Trim()
node .github/scripts/smoke-pty.mjs
- name: daemon start / status / stop over the named pipe
shell: pwsh
run: |
# pwsh does not halt on a native (non-PowerShell) command's non-zero
# exit, and GitHub Actions only inspects $LASTEXITCODE once, after the
# whole script runs — so without explicit gates the step's verdict
# would come from `tlive stop` alone, which exits 0 even when nothing
# was running (stop is deliberately idempotent so `stop && start`
# chains work). Gate every command, and assert on `status`'s actual
# text rather than just its exit code: match the "running (pid ...)"
# line status.ts prints, not a bare "running" substring — "not
# running" also contains the word "running".
tlive start
if ($LASTEXITCODE -ne 0) { throw "tlive start failed ($LASTEXITCODE)" }
$s = tlive status | Out-String
if ($LASTEXITCODE -ne 0) { throw "tlive status failed ($LASTEXITCODE)" }
Write-Host $s
if ($s -notmatch 'daemon:\s+running \(pid') { throw "daemon not reported running:`n$s" }
tlive stop
if ($LASTEXITCODE -ne 0) { throw "tlive stop failed ($LASTEXITCODE)" }
- name: stop the daemon even if a step above failed
if: always()
shell: pwsh
run: tlive stop
continue-on-error: true
20 changes: 20 additions & 0 deletions src/kernel/__tests__/wait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Condition-based waiting for tests.
//
// Fixed `setTimeout` waits before an arrival assertion are the project's main
// source of Windows CI flake: named pipes are slower than unix sockets, so a
// margin that holds on Linux does not hold there (#45, #59's sibling red).
// vitest's default waitFor timeout is 1s, which is too tight for daemon
// bootstrap over a pipe.
//
// Use this for ARRIVAL assertions — "X has appeared". Do NOT use it for
// ABSENCE assertions — "X did not happen" needs a real elapsed interval, and a
// condition that is already true returns immediately, asserting nothing.

import { vi } from 'vitest';

const TIMEOUT_MS = 5000;
const INTERVAL_MS = 20;

export function until(assertion: () => void | Promise<void>): Promise<void> {
return vi.waitFor(assertion, { timeout: TIMEOUT_MS, interval: INTERVAL_MS }) as Promise<void>;
}
32 changes: 12 additions & 20 deletions src/kernel/daemon/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { bootstrapDaemon, shouldFastNullContinue, clampPermissionTimeout, makeCo
import { request, daemonSocketPath } from '../../ipc/client';
import type { IMAdapter, IMChannel, OutgoingMessage, IncomingEnvelope } from '../../contracts/im-adapter';
import { SessionRegistry } from '../../web/session-registry';
import { until } from '../../__tests__/wait.js';

// #45 — robustness helpers for this file's "held request" pattern
// (const pending = request(...); …asserts…; await pending). On a slow/jittery
Expand Down Expand Up @@ -177,7 +178,7 @@ describe('local-answer cancel + Stop fast-null (integration)', () => {
{ socketPath: sock, timeoutMs: 10_000 },
);
held(pending);
await new Promise((r) => setTimeout(r, 100)); // let the card go pending
await until(() => { expect(h.sessions.get('s1')?.pending).toBeDefined(); }); // let the card go pending
await request(
{ kind: 'hook.event', event: { event: 'activity', cwd: '/w', sessionId: 's1', toolName: 'Bash', result: {} } },
{ socketPath: sock, timeoutMs: 2000 },
Expand Down Expand Up @@ -344,8 +345,7 @@ describe('AskUserQuestion remote card (Task 9)', () => {
{ socketPath: sock, timeoutMs: 10_000 },
);
held(pending);
await new Promise((r) => setTimeout(r, 100));
expect(notes).toHaveLength(1); // exactly once — not once per configured channel
await until(() => { expect(notes).toHaveLength(1); }); // exactly once — not once per configured channel
expect(notes[0].title).toContain('Bash');
const card = sent[0] as { kind: 'card'; buttons?: Array<{ id: string; label: string }> };
adapter.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x1', text: card.buttons!.find((b) => b.id.startsWith('approve:'))!.id, ts: 0 });
Expand All @@ -372,9 +372,8 @@ describe('AskUserQuestion remote card (Task 9)', () => {
{ socketPath: sock, timeoutMs: 10_000 },
);
held(pending);
await new Promise((r) => setTimeout(r, 150));
await until(() => { expect(notes).toHaveLength(1); });
expect(sent).toHaveLength(0); // …but the desktop already knows
expect(notes).toHaveLength(1);
// Local answer within grace (PostToolUse cancel) → card never sent, toast cleared.
await request(
{ kind: 'hook.event', event: { event: 'activity', cwd: '/w', sessionId: 's1', toolName: 'Bash', result: {} } },
Expand Down Expand Up @@ -405,9 +404,8 @@ describe('AskUserQuestion remote card (Task 9)', () => {
{ socketPath: sock, timeoutMs: 10_000 },
);
held(pending);
await new Promise((r) => setTimeout(r, 100));
await until(() => { expect(sent).toHaveLength(1); });
expect(notes).toHaveLength(0); // toast gated off; the IM card still went out
expect(sent).toHaveLength(1);
const card = sent[0] as { kind: 'card'; buttons?: Array<{ id: string; label: string }> };
adapter.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x1', text: card.buttons!.find((b) => b.id.startsWith('approve:'))!.id, ts: 0 });
await pending;
Expand Down Expand Up @@ -437,8 +435,7 @@ describe('AskUserQuestion remote card (Task 9)', () => {
{ socketPath: sock, timeoutMs: 10_000 },
);
held(pending);
await new Promise((r) => setTimeout(r, 150));
expect(sent).toHaveLength(2); // one card per channel…
await until(() => { expect(sent).toHaveLength(2); }); // one card per channel…
expect(notes).toHaveLength(1); // …but a single desktop ping
const card = sent[0] as { kind: 'card'; buttons?: Array<{ id: string; label: string }> };
tg.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x1', text: card.buttons!.find((b) => b.id.startsWith('approve:'))!.id, ts: 0 });
Expand All @@ -458,9 +455,8 @@ describe('AskUserQuestion remote card (Task 9)', () => {
// and Codex turn/completed paths both funnel through). Floating request with
// a tiny window; we only assert the notification surfaces, not the reply.
void h.continueBroker.request({ cwd: '/w', context: 'Finished building the feature', timeoutSec: 1 });
await new Promise((r) => setTimeout(r, 50));
await until(() => { expect(sent).toHaveLength(1); }); // …still surfaced on IM
expect(infos).toHaveLength(0); // no desktop flood on completion…
expect(sent).toHaveLength(1); // …still surfaced on IM
expect((sent[0] as { title?: string }).title).toContain('Turn finished');
});

Expand Down Expand Up @@ -545,7 +541,7 @@ describe('AskUserQuestion remote card (Task 9)', () => {
{ socketPath: sock, timeoutMs: 10_000 },
);
held(pending);
await new Promise((r) => setTimeout(r, 100));
await waitForSent(sent); // let the card go out before referencing its messageId
const cardMsgId = 'm1'; // interactiveAdapter assigns sequential ids m1, m2, …
adapter.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x2', text: 'use mv to the scratchpad instead', replyToMessageId: cardMsgId, ts: 0 });
const r = await pending as { decision?: string; message?: string };
Expand Down Expand Up @@ -645,15 +641,13 @@ describe('AskUserQuestion remote card (Task 9)', () => {
// fix) — the edit lands on a later microtask, no longer synchronously
// within fire(), so each assertion needs a tick to let the queue drain.
adapter.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x1', text: toggleBlue.id, ts: 0 });
await new Promise((r) => setTimeout(r, 0));
expect(edits).toHaveLength(1);
await until(() => { expect(edits).toHaveLength(1); });
const edited1 = edits[0].msg as { kind: 'card'; buttons?: Array<{ id: string; label: string }> };
expect(edited1.buttons!.map((b) => b.label)).toEqual(['▢ Red', '▣ Blue', 'Submit (1)', 'Skip']);

// toggling the same option again flips it back off
adapter.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x1', text: toggleBlue.id, ts: 0 });
await new Promise((r) => setTimeout(r, 0));
expect(edits).toHaveLength(2);
await until(() => { expect(edits).toHaveLength(2); });
const edited2 = edits[1].msg as { kind: 'card'; buttons?: Array<{ id: string; label: string }> };
expect(edited2.buttons!.map((b) => b.label)).toEqual(['▢ Red', '▢ Blue', 'Submit (0)', 'Skip']);

Expand Down Expand Up @@ -876,8 +870,7 @@ describe('quoting a live approval card = deny with guidance (Task 7)', () => {
expect(r.decision).toBe('deny');
expect(r.message).toBe('Do not use rm -rf, move it to /tmp/.trash instead');

await new Promise((res) => setTimeout(res, 50)); // let the queued settlement edit land
expect(edits).toHaveLength(1);
await until(() => { expect(edits).toHaveLength(1); }); // let the queued settlement edit land
const editedTitle = (edits[0].msg as { title?: string }).title ?? '';
expect(editedTitle).toContain('Denied with guidance');
});
Expand All @@ -904,8 +897,7 @@ describe('quoting a live approval card = deny with guidance (Task 7)', () => {
adapter.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x1', text: denyBtn.id, ts: 0 });
await pending;

await new Promise((res) => setTimeout(res, 50));
expect(edits).toHaveLength(1);
await until(() => { expect(edits).toHaveLength(1); });
const editedTitle = (edits[0].msg as { title?: string }).title ?? '';
expect(editedTitle).toContain('Denied');
expect(editedTitle).not.toContain('Denied with guidance');
Expand Down
Loading
Loading