| author | |
| committer | |
| log | d9c1732b2791f762bce06e4898cdb6679ce24961 |
| tree | d37ae9fb1b6f84dde7ad265d069de91c8ba6a697 |
| parent | 49c2b4471c59b21759eb2e254e9c4fa2579888a3 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 54 insertions(+), 10 deletions(-)
packages/tailscale/src/tailscale.test.ts+39| ... | ... | @@ -212,6 +212,45 @@ describe("tailscale", () => { |
| 212 | 212 | }); |
| 213 | 213 | }); |
| 214 | 214 | |
| 215 | it.effect("turns spawn defects into typed spawn failures", () => { | |
| 216 | // A non-directory entry on PATH makes node's spawn throw ENOTDIR | |
| 217 | // synchronously. The platform spawner calls `NodeChildProcess.spawn` from | |
| 218 | // inside an `Effect.callback` registration, so that throw arrives as a | |
| 219 | // defect rather than a typed error - the shape reproduced here. | |
| 220 | const defect = Object.assign(new Error("spawn tailscale ENOTDIR"), { code: "ENOTDIR" }); | |
| 221 | const layer = Layer.succeed( | |
| 222 | ChildProcessSpawner.ChildProcessSpawner, | |
| 223 | ChildProcessSpawner.make(() => | |
| 224 | Effect.callback<never, never>(() => { | |
| 225 | throw defect; | |
| 226 | }), | |
| 227 | ), | |
| 228 | ); | |
| 229 | ||
| 230 | return Effect.gen(function* () { | |
| 231 | const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); | |
| 232 | assert.instanceOf(statusError, TailscaleCommandSpawnError); | |
| 233 | assert.equal(statusError.subcommand, "status"); | |
| 234 | assert.strictEqual(statusError.cause, defect); | |
| 235 | ||
| 236 | const serveError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( | |
| 237 | Effect.flip, | |
| 238 | Effect.provide(layer), | |
| 239 | ); | |
| 240 | assert.instanceOf(serveError, TailscaleCommandSpawnError); | |
| 241 | assert.equal(serveError.subcommand, "serve"); | |
| 242 | assert.strictEqual(serveError.cause, defect); | |
| 243 | ||
| 244 | // What callers actually rely on: the desktop endpoint providers recover | |
| 245 | // with `Effect.orElseSucceed`, which only sees the typed error channel. | |
| 246 | const degraded = yield* readTailscaleStatus.pipe( | |
| 247 | Effect.orElseSucceed(() => null), | |
| 248 | Effect.provide(layer), | |
| 249 | ); | |
| 250 | assert.equal(degraded, null); | |
| 251 | }); | |
| 252 | }); | |
| 253 | ||
| 215 | 254 | it.effect("keeps nonzero exit diagnostics structured", () => { |
| 216 | 255 | const layer = mockSpawnerLayer(() => ({ |
| 217 | 256 | code: 7, |
packages/tailscale/src/tailscale.ts+15-10| ... | ... | @@ -228,11 +228,15 @@ export const readTailscaleStatus = Effect.gen(function* () { |
| 228 | 228 | argumentCount: args.length, |
| 229 | 229 | }; |
| 230 | 230 | return yield* Effect.gen(function* () { |
| 231 | const child = yield* spawner | |
| 232 | .spawn(ChildProcess.make(executable, args)) | |
| 233 | .pipe( | |
| 234 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), | |
| 235 | ); | |
| 231 | const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( | |
| 232 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), | |
| 233 | // Spawning can also fail as a defect rather than a typed error - a | |
| 234 | // non-directory entry on PATH makes node throw ENOTDIR synchronously. | |
| 235 | // `mapError` never sees that, so it would escape as an uncaught error. | |
| 236 | Effect.catchDefect((cause) => | |
| 237 | Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), | |
| 238 | ), | |
| 239 | ); | |
| 236 | 240 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 237 | 241 | [ |
| 238 | 242 | collectStdout(child.stdout), |
| ... | ... | @@ -299,11 +303,12 @@ const runTailscaleCommand = ( |
| 299 | 303 | }; |
| 300 | 304 | const timeout = Duration.fromInputUnsafe(timeoutInput); |
| 301 | 305 | return yield* Effect.gen(function* () { |
| 302 | const child = yield* spawner | |
| 303 | .spawn(ChildProcess.make(executable, args)) | |
| 304 | .pipe( | |
| 305 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), | |
| 306 | ); | |
| 306 | const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( | |
| 307 | Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), | |
| 308 | Effect.catchDefect((cause) => | |
| 309 | Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), | |
| 310 | ), | |
| 311 | ); | |
| 307 | 312 | const [stderr, exitCode] = yield* Effect.all( |
| 308 | 313 | [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], |
| 309 | 314 | { concurrency: "unbounded" }, |