Skip to content
Source
Sign inSign up

[Bug]: Simlock leases expire while watched when the gateway limits TTL to five minutes #35982

Send to my agent
Closedcoygeek(GitHub profile, opens in a new tab) opened this issue 0 comments

Summary

A Simlock device can open successfully and then expire while its Device panel is still being watched when the gateway permits five-minute leases. T3 tries to renew it with a longer TTL that the gateway refuses, so its keepalive never extends the lease. A local HTTP gateway reproduction using the actual host adapter establishes the rejected renewals and eventual expired-lease response; the same adapter keeps the control lease alive when the gateway permits the requested TTL.

Before submitting

  • Related issues: none found in open and closed issues or relevant pull requests for Simlock TTL renewal.
  • Removed credentials, tokens, private paths, and sensitive data; the script uses a fake fixture credential only.

Area

apps/server

Observed behavior

With a newly opened five-minute lease and lease.maxTtlMs=300000, the actual SimlockDeviceHost sends POST /v1/leases/fixture-lease/renew with {"ttlMs":600000} at two and four minutes of virtual time. The local gateway returns HTTP 400 for both attempts because that TTL exceeds its configured ceiling. At six minutes it returns HTTP 404 because the original lease deadline passed. A control gateway allowing 600000 accepts all three renewals and advances the deadline. The fake CLI only creates the session connection record; it never executes agent-device or allocates a real device.

Reproduction

  1. Use a checkout at the commit below with its pinned dependencies installed (vp install --frozen-lockfile --ignore-scripts --filter 't3...' --filter '@t3tools/client-runtime...').
  2. Save the following script as repro-renewal.mjs and run node repro-renewal.mjs /path/to/checkout.
  3. Compare the control's accepted renewals with the five-minute gateway's two rejected renewals and expired-lease response.

The finalized script reproduced deterministically in one run. It imports the actual host adapter and pinned Effect runtime, starts a loopback-only HTTP server, replaces only the CLI/process boundary, and advances an Effect test clock. It puts HOME, TMPDIR, T3 state, and fake session files under a new temporary directory, removes that directory and closes its own server on exit, and leaves checkout files unchanged. Node, Git, and the checkout's installed pinned dependencies are required. Exit 1 means the defect reproduced, 0 means the expected renewal behavior, and 2 means setup or the control failed.

Reproduction script: repro-renewal.mjs
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createServer } from 'node:http';
const root = resolve(process.argv[2] || '.');
const scratch = mkdtempSync(join(tmpdir(), 't3-simlock-renew-'));
for (const name of ['HOME', 'TMPDIR', 'T3CODE_HOME']) { process.env[name] = join(scratch, name); mkdirSync(process.env[name]); }
const require = createRequire(join(root, 'apps/server/package.json'));
const load = name => import(pathToFileURL(require.resolve(name)).href);
const local = name => import(pathToFileURL(join(root, name)).href);
let result = 2;
let server;
try {
 console.log('Node ' + process.version + ' (' + process.platform + ' ' + process.arch + ')');
 console.log(execFileSync('git', ['--version'], {encoding:'utf8'}).trim());
 console.log('T3 ' + execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], {encoding:'utf8'}).trim());
 console.log('Effect ' + JSON.parse(readFileSync(require.resolve('effect/package.json'))).version);
 const [Effect, Layer, TestClock, NodeServices, HttpClient, HttpClientResponse, ProcessRunner, ServerConfig, SecretResolver, Host] = await Promise.all([
  load('effect/Effect'),load('effect/Layer'),load('effect/testing/TestClock'),load('@effect/platform-node/NodeServices'),load('effect/http/HttpClient'),load('effect/http/HttpClientResponse'),
  local('apps/server/src/processRunner.ts'),local('apps/server/src/config.ts'),local('packages/sec' + 'ret-provider-core/src/server/SecretResolver.ts'),local('apps/server/src/device/SimlockDeviceHost.ts')
 ]);
 const requests=[]; let now=0, maxTtl=600000, deadline=300000, closes=0;
 server=createServer(async(req,res)=>{
  let raw=''; for await (const chunk of req) raw+=chunk;
  const body=raw?JSON.parse(raw):{};
  let status=200, json={};
  if(req.headers.authorization!=='Bearer fixture-token') status=401;
  else if(req.url==='/v1/catalog') json={platforms:[{platform:'ios',modelRuntimes:{'iPhone 16':['27.0']}}]};
  else if(req.url.endsWith('/renew')) {
   if(now>=deadline) status=404;
   else if(body.ttlMs>maxTtl) {status=400;json={error:{code:'BAD_REQUEST',message:'ttlMs exceeds lease.maxTtlMs'}};}
   else deadline=now+(body.ttlMs ?? 300000);
  } else if(req.method==='DELETE') status=202;
  else status=404;
  requests.push({method:req.method,path:req.url,body,status});
  res.writeHead(status,{'content-type':'application/json'});res.end(JSON.stringify(json));
 });
 await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
 const gateway='http://127.0.0.1:'+server.address().port;
 const http=HttpClient.make(request=>Effect.promise(async()=>{
  const response=await fetch(request.url,{method:request.method,headers:request.headers,body:request.body._tag==='Uint8Array'?request.body.body:undefined});
  return HttpClientResponse.fromWeb(request,response);
 }));
 const runner=ProcessRunner.ProcessRunner.of({run:input=>Effect.sync(()=>{
  const args=input.args.slice(1), config=JSON.parse(readFileSync(args[args.indexOf('--config')+1], 'utf8'));
  const session=args[args.indexOf('--session')+1];
  if(args[0]==='open') {
   mkdirSync(join(config.stateDir,'remote-connections'),{recursive:true});
   writeFileSync(join(config.stateDir,'remote-connections',session+'.json'), JSON.stringify({leaseId:'fixture-lease',lease:{ttlMs:300000,ttlDeadline:deadline}}));
  }
  if(args[0]==='close') closes++;
  return {stdout:'{}',stderr:'',code:0,timedOut:false,stdoutTruncated:false,stderrTruncated:false,stdoutInvalidUtf8:false,stderrInvalidUtf8:false};
 })});
 // A response latch synchronizes each virtual-clock advance with the real local HTTP response.
 const latches=[]; const observedHttp=HttpClient.make(request=>http.execute(request).pipe(Effect.tap(()=>Effect.sync(()=>latches.shift()?.()))));
 async function scenario(label,limit) {
  now=0;maxTtl=limit;deadline=300000;requests.length=0;
  const program=Effect.gen(function*(){
   const host=yield* Host.make({kind:'simlock',id:'fixture',label:'Fixture',gatewayUrl:gateway,agentToken:'fixture-token',maxLeases:1,maxLeaseMinutes:60},Effect.succeed({nodePath:'node',entryPath:'fake-agent-device.mjs'}));
   const ready=yield* host.ensureReady(()=>Effect.void);
   const device=(yield* ready.devices.list).devices[0];
   yield* ready.devices.boot(device);
   for(const step of [120000,240000,360000]) {
    now=step; const received=new Promise(resolve=>latches.push(resolve));
    yield* TestClock.adjust('2 minutes');
    yield* Effect.promise(()=>received);
   }
   const renews=requests.filter(r=>r.path.endsWith('/renew'));
   console.log(label+': '+JSON.stringify(renews.map(r=>({at:renews.indexOf(r)*120000+120000,ttlMs:r.body.ttlMs,status:r.status}))));
   console.log(label+': deadline='+deadline+' now='+now);
   return renews;
  }).pipe(Effect.scoped,Effect.provideService(HttpClient.HttpClient,observedHttp),Effect.provideService(ProcessRunner.ProcessRunner,runner),Effect.provideService(ServerConfig.ServerConfig,{stateDir:join(scratch,'state')}),Effect.provideService(SecretResolver.SecretResolver,{read:()=>Effect.succeed([]),invalidate:()=>Effect.void,label:p=>Effect.succeed(p)}),Effect.provide(Layer.mergeAll(NodeServices.layer,TestClock.layer())));
  return await Effect.runPromise(program);
 }
 const control=await scenario('control maxTtlMs=600000',600000);
 if(!control.every(r=>r.status===200)) throw Error('control failed');
 const failing=await scenario('gateway maxTtlMs=300000',300000);
 if(failing[0]?.status===400&&failing[1]?.status===400&&failing[2]?.status===404) result=1;
 else if(failing.every(r=>r.status===200)) result=0;
 else throw Error('unexpected renewal results');
} catch(e) { console.error(String(e)); result=2; }
finally { if(server) await new Promise(resolve=>server.close(resolve));rmSync(scratch,{recursive:true,force:true}); }
console.log(result===1?'RESULT: defect reproduced':result===0?'RESULT: expected behavior':'RESULT: inconclusive');
process.exitCode=result;

Expected behavior

A watched lease remains renewed until T3's configured maximum lease age or an explicit lease end, without asking the gateway for a TTL it disallows. docs/user/devices.md#L150-L154 promises that the panel keeps the watched device leased up to the host's lease limit. Simlock's configuration reference allows a positive lease.maxTtlMs and rejects an over-limit renewal rather than shortening it. Its lease lifecycle requires renewal before expiry and permits a renewal without a new TTL to retain the existing lease duration.

Impact and scope

Impact: P2 - A supported gateway TTL configuration causes an idle watched device to lose its lease before T3's configured maximum age. Simlock documents deletion of the device after expiry; this reproduction observes HTTP expiry responses only and does not run a device or observe deletion.

The observed condition is lease.maxTtlMs=300000, an initial five-minute lease, and no intervening agent commands. A gateway permitting T3's ten-minute renewal TTL worked in the control. An operator can use that configuration if its deployment permits longer TTLs. NOT_ENOUGH_INFO: real deployment frequency and other TTL configurations were not measured; device-backed validation would establish that scope.

Version

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; apps/server package 0.1.9. Simlock behavior is modeled from source revision d30c24d8596dc27ce2f7bf4361199db1968f8a34, rather than a running deployment. The T3-pinned agent-device version is 0.21.36; no agent-device executable ran.

Runtime and platform

Node v26.12.1, Git 2.56.0, Effect 4.1.3, macOS (darwin) arm64. No browser, simulator, phone, real token, or Simlock daemon was used.

Interface

Library and HTTP: Simlock host adapter renewal lifecycle.

Earliest failure stage

Cleanup or lifecycle.

Relevant output or evidence

The finalized script printed the following result; Effect warning objects are omitted because the status sequence already records the refusal.

control maxTtlMs=600000: [{"at":120000,"ttlMs":600000,"status":200},{"at":240000,"ttlMs":600000,"status":200},{"at":360000,"ttlMs":600000,"status":200}]
control maxTtlMs=600000: deadline=960000 now=360000
gateway maxTtlMs=300000: [{"at":120000,"ttlMs":600000,"status":400},{"at":240000,"ttlMs":600000,"status":400},{"at":360000,"ttlMs":600000,"status":404}]
gateway maxTtlMs=300000: deadline=300000 now=360000
RESULT: defect reproduced

Source evidence: apps/server/src/device/SimlockDeviceHost.ts#L40-L47 sets the renewal interval and requested TTL; apps/server/src/device/SimlockDeviceHost.ts#L412-L437 repeats that TTL, logs rejected renewals, and forgets the lease on 404. Restoration check: the same reproduction must retain a valid five-minute lease beyond its original expiry without over-limit renewal requests, while the control continues to succeed.

  1. tanstack-source added labelsbug

  2. juliusmarminge closed this

Sign in to join this conversation.